Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 f8f03b1416 Merge feature/phase-a-templates (with develop) into feature/phase-c-tracking
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:21:37 -05:00
james.maesandClaude Opus 5.5 8c0da3dfad Merge develop into feature/phase-a-templates
Brings in phase (b); README and CHANGELOG combine both, DESIGN 7.3 keeps
the ClassifiedValueTokens wording with the env1 key id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:19:22 -05:00
james.maes aa37cfb53c Merge pull request #8 from QRun-IO/feature/phase-b-providers
feat: phase (b) - Postmark, SendGrid, Mailgun and Gmail SMTP
2026-10-14 07:09:21 -05:00
james.maes ee2345682b Merge pull request #6 from QRun-IO/feature/phase-a-outbox
feat: phase (a) part 1 - outbox, providers, SMTP and SES, messaging bridge
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 40d54226b2 fix: review findings on tracking, and a template API for other qbits
- Webhook inbox rows apply matched events independently; unmatched
  events retry, then are skipped with a note instead of failing the row.
- Notification and marketing messages are never deleted by retention,
  so unsubscribe links keep working; their bodies are still cleared.
- Events only suppress recipients of the matched message; an SES
  bounce without recipients no longer suppresses every destination.
- SNS notifications outside a 1 h / +5 min window are refused, and
  failed certificate fetches are cached for 5 minutes.
- EmailTemplateDefinition and EmailTemplates.ensure let other qbits
  register their templates once without overwriting edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:00:26 -05:00
james.maesandClaude Opus 5.5 4988a0740f Merge feature/phase-a-templates into feature/phase-c-tracking
Keeps the polished home layout with phase (c)'s event-backed metrics.
Also fixes unsubscribe privacy: a message with an unsubscribe link goes
to exactly one recipient, its token binds that address, and the handler
suppresses only that address (DESIGN 8.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:28:42 -05:00
james.maesandClaude Opus 5.5 6af5062aa7 feat: delivery tracking, suppression from events, and one-click unsubscribe
Phase (c) of docs/DESIGN.md: the email_webhook_inbox and email_event
tables; a /qqq/hooks/email route provider with SES (SNS signature and
certificate host rules, subscription confirmation), Postmark (basic
auth), SendGrid (ECDSA) and Mailgun (HMAC with replay protection)
verification into a durable inbox; a claim-safe inbox processor that
maps provider events to email_event rows, message status and
suppressions; RFC 8058 one-click unsubscribe with a confirmation page;
the Reprocess action; and event-backed home metrics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:50 -05:00
james.maesandClaude Opus 5.5 ab1e7c0fec fix: match core ClassifiedValueTokens exactly in the token stand-in
Key id from QQQ_AUDIT_HMAC_KEY_ID (default env1), canonical values
through ValueUtils, String.valueOf for record keys, and core's verify
argument order. A test pins the stand-in to core's published context
vector (QRun-IO/qqq#1182).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:12:55 -05:00
james.maesandClaude Opus 5.5 9f7aed6e1c feat: polish the Email home, record labels and the dark preview
Number-first tiles with plain labels, bar charts for sends per hour and
suppressions added, a compact recent-messages table that stacks on
phones, compact UTC dates, plain provider status words, record labels
for versions, messages and providers, and dark-mode button colours in
the preview that pass 4.5:1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:57:24 -05:00
james.maes 17c7dc1140 Merge remote-tracking branch 'origin/feature/phase-b-providers' into feature/phase-c-tracking
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-13 21:37:12 -05:00
james.maesandClaude Opus 5.5 10e96115f9 fix: review findings on templates, PHI and preview; add a local demo
- Template sends keep the template's category.
- Render budget (10,000 iterations, 1 MiB), nesting 8, linear parsing.
- URL attributes in HTML and MJML accept only http, https or mailto;
  unquoted attribute expressions are refused.
- MJML includes refused and disabled; the compiler runs with PATH only.
- Record variables load only referenced fields; records without a
  classification source are tokenized in snapshots.
- Approved templates keep their brand; brand URLs are https and image
  hosts are restricted for PHI mail.
- Viewers never trigger an MJML compile from the preview.
- Button links match a single link.
- PHI ciphertexts are bound to table, column and message (AES-GCM AAD).
- EmailDemoServer (test scope) seeds 24 hours of real traffic for
  screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:36:49 -05:00
james.maesandClaude Opus 5.5 d8d3d1cabd feat: Postmark, SendGrid and Mailgun transports and the Gmail SMTP preset
Phase (b) of docs/DESIGN.md: HTTP JSON and MIME transports over
java.net.http with fixed HTTPS endpoints (Mailgun US and EU), error
mapping to permanent, transient (with Retry-After) and provider-level
failures, tracking flags and message streams, per-kind validation and
env-name allowlists, the Gmail SMTP preset, and docs/PROVIDERS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:11:24 -05:00
james.maesandClaude Opus 5.5 4a49515fc4 docs: drop the interim part-2 migration note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:06:59 -05:00
james.maes f318ff79a9 Merge remote-tracking branch 'origin/feature/phase-a-outbox' into feature/phase-a-templates 2026-10-13 21:04:24 -05:00
james.maesandClaude Opus 5.5 1752ba5f7f feat: templates, branding, PHI rules and the app home
Phase (a) part 2 of docs/DESIGN.md: the variable engine with escaping,
if/each and fallbacks; variable schemas; Markdown in the branded
responsive layout with dark-mode colours and contrast checks; HTML and
MJML kinds (MJML through a pluggable compiler at activation); versions
with New Version, Activate Version and Approve For PHI; templated sends
with record variables read as the sender; the classification stand-in
(core #1142); PII/PHI refusal; redacted variable snapshots with
ClassifiedValueTokens-shaped tokens and encrypted PHI bodies (stand-ins
for core #1161); the secure-message built-in; the preview widget; the
home dashboard; contingent possible values; and the canary-PHI egress
test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:04:23 -05:00
james.maes 35fa331884 Merge pull request #5 from QRun-IO/feature/design-updates
docs: design updates (no filesystem, token bindings, preferences accessor)
2026-10-13 19:42:44 -05:00
james.maesandClaude Opus 5.5 9a3461fdf3 docs: no-filesystem rule, ClassifiedValueTokens bindings and the preferences email accessor
D11 records the owner rule that only the database and S3 hold data.
Section 7.3 follows secured-data section 14.5 (record-field and
domain-prefixed bindings, changed:v1 for low-entropy context values).
Section 8.3 uses UserPreferenceRequestContext.getUserEmailAddress().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:41:01 -05:00
james.maes 31ecdae873 Merge pull request #4 from QRun-IO/feature/design-spec
docs: qbit-email design spec
2026-10-13 19:29:51 -05:00
92 changed files with 12588 additions and 201 deletions

No files matched your search

+7 -2
View File
@@ -20,14 +20,19 @@ Design: `docs/DESIGN.md`.
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
- `outbox`: EmailService, requests/results and the replaceable ad-hoc renderer.
- `outbox`: EmailService, queue-time template rendering, protected resend and branded ad-hoc rendering.
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
- Templates/branding/PHI/full home rendering are part 2; their tables exist now.
- `template`: parsed variable language/schema, locale resolution, immutable versions, version-bound PHI approval, MJML compilation, lazy secure-message built-in and inline preview.
- `render`: CommonMark extensions, table-based light/dark layout, contrast validation, text conversion and unsubscribe-link signing (handler is phase c).
- `phi`: RC.5 classification/token stand-ins and AES-GCM encryption; only dispatch decrypts bodies.
- `app/EmailHome`: viewer-scoped counts and inline cards, with standard hourly/suppression chart widgets.
- Version operations serialize on the template row; active/approved version selectors are parent-scoped.
- Preview UI API endpoint awaits RC.6; webhook/unsubscribe handlers remain phase c.
## Invariants
+13
View File
@@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
### Added
- Phase (c): authenticated SNS, Postmark, SendGrid and Mailgun webhooks, durable inbox deduplication and Mailgun nonce replay protection; configurable Javalin routes.
- Atomic scheduled event processing, per-recipient event history, monotonic delivery state, category-aware suppression, optional daily complaint alerts and FAILED inbox Reprocess with Review/Result.
- One-click unsubscribe and escaped self-contained light/dark confirmation pages using the existing envelope-bound signer; account/security tokens refused.
- Event-based home totals, PostgreSQL/H2 tracking DDL, provider setup instructions, test-only SNS certificate/key and real HTTP, crypto, parser, concurrency, retry and privacy tests. Preferences stays deferred until core #1162 reaches an RC.
- Phase (a), part 2: versioned Markdown/HTML/MJML templates, typed variable schemas, escaped blocks/loops/fallbacks, branded light/dark layout and contrast validation.
- New Version, Activate Version and version-bound Approve For PHI actions, edit permissions, immutable history and parent-scoped version selectors.
- Sender-scoped record variables, classification stand-in, HMAC snapshot tokens, AES-GCM bodies, encrypted query displays and protected resend.
- Lazy secure-message built-in, typed templated core bridge, process JSON variables, inline preview and viewer-scoped dashboard charts/cards.
- PHI canary egress, masking, lifecycle, parent-filter, rendering, branding and GreenMail template tests.
- Phase (b) Postmark, SendGrid and Mailgun HTTP transports with fixed HTTPS endpoints, bounded requests, tracking options, safe error classification and Retry-After support; no new dependencies or filesystem storage.
- Strict Gmail SMTP credential/STARTTLS validation, provider-specific credential and webhook environment defaults, and the [provider setup guide](docs/PROVIDERS.md).
- HTTP request-contract tests and local-server end-to-end outbox tests covering success, provider failures, permanent rejection, retries and secret-safe stored errors.
- Phase (a), part 1 email tables, PostgreSQL DDL, enum sources, child relations, permissions and Email app navigation.
- Provider validation with environment-name allowlists and presence-only credential displays.
- Transactional ad-hoc EmailService, idempotency, suppression and a replaceable Markdown/HTML/text renderer.
+7 -2
View File
@@ -20,14 +20,19 @@ Design: `docs/DESIGN.md`.
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
- `outbox`: EmailService, requests/results and the replaceable ad-hoc renderer.
- `outbox`: EmailService, queue-time template rendering, protected resend and branded ad-hoc rendering.
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
- Templates/branding/PHI/full home rendering are part 2; their tables exist now.
- `template`: parsed variable language/schema, locale resolution, immutable versions, version-bound PHI approval, MJML compilation, lazy secure-message built-in and inline preview.
- `render`: CommonMark extensions, table-based light/dark layout, contrast validation, text conversion and unsubscribe-link signing (handler is phase c).
- `phi`: RC.5 classification/token stand-ins and AES-GCM encryption; only dispatch decrypts bodies.
- `app/EmailHome`: viewer-scoped counts and inline cards, with standard hourly/suppression chart widgets.
- Version operations serialize on the template row; active/approved version selectors are parent-scoped.
- Preview UI API endpoint awaits RC.6; webhook/unsubscribe handlers remain phase c.
## Invariants
+250 -28
View File
@@ -1,20 +1,20 @@
# QBit Email
Phase (a), part 1: a durable email outbox for QQQ, with SMTP and Amazon SES transports,
suppression, retries, provider health, record actions, and the core messaging bridge.
Every send is queued in `email_message` before a dispatcher contacts a provider.
One email service for a QQQ system: a durable outbox (`email_message`) with retries, dead
letters, idempotency keys, rate limits and provider health; SMTP, Amazon SES, Postmark,
SendGrid, Mailgun and Gmail SMTP transports; versioned Markdown, HTML and MJML templates with
dark-mode-safe branding; classified variables and PHI protection; preview widgets; a
real-data home; delivery events, suppression and one-click unsubscribe; and the core messaging bridge. Every send goes through the outbox.
The approved [design](docs/DESIGN.md) covers later phases too. This implementation
accepts ad-hoc Markdown, HTML and text. Template rendering, branding, PHI handling and
the full home dashboard belong to part 2. Template and variable-bearing requests are
refused in part 1; attachments are refused. Tracking webhooks, unsubscribe and the
additional HTTP transports remain later phases.
The [design](docs/DESIGN.md) also specifies the deferred Preferences UI (core #1162),
consumer migrations and inbound mail. Preview uses the standard widget endpoint; the
dedicated preview API arrives with QQQ RC.6. Attachments are refused until
qbit-object-storage is wired in.
## Install
Requires Java 21, QQQ **4.1.0-RC.5**, and a transactional RDBMS with the supplied unique
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing
the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
The physical table names equal their metadata names; `backendName` selects the host's
QQQ database backend, as it does in qbit-webhooks.
@@ -45,9 +45,11 @@ in a static variable.
|---|---|---|
| `email_message` | Messages | Qbit only; people use record actions |
| `email_message_attempt` | Attempts on a message | Qbit only |
| `email_template` | Templates | `email.manage`; rendering arrives in part 2 |
| `email_template_version` | Versions on a template | `email.manage`; activation arrives in part 2 |
| `email_brand` | Brands | `email.manage`; rendering arrives in part 2 |
| `email_event` | Events on a message | Qbit only; readable with `email.view` |
| `email_webhook_inbox` | Inbox on a provider | Qbit only; readable with `email.view`; Reprocess requires `email.manage` |
| `email_template` | Templates | `email.manage`; template edit grants also apply |
| `email_template_version` | Versions on a template | Draft edits only; lifecycle uses record actions |
| `email_brand` | Brands | `email.manage`; template edit grants also apply |
| `email_suppression` | Suppressions | `email.manage`; changing/removing a complaint needs `email.admin` |
| `email_provider` | Providers, with child Messages | `email.admin`, including reads and test sends |
@@ -67,6 +69,19 @@ insert/update/delete capabilities. Labels contain no status decorations.
## Providers and credentials
See the [provider setup guide](docs/PROVIDERS.md) for row fields, DNS/domain setup,
limits, Gmail app passwords and provider webhook setup.
| Kind | Transport | Status |
|---|---|---|
| SMTP | Relay or authenticated SMTP, STARTTLS / TLS | Available |
| SES | AWS SES v2, raw MIME | Available |
| POSTMARK | HTTPS JSON, server token and message stream | Available |
| SENDGRID | HTTPS JSON, bearer API key | Available |
| MAILGUN | HTTPS MIME multipart, US / EU | Available |
| GMAIL_SMTP | smtp.gmail.com:587, STARTTLS and app password | Available |
| GMAIL_API | OAuth Gmail API | Deferred; use GMAIL_SMTP |
Create the first enabled provider with `isDefault=true`. When any providers are
enabled, exactly one must be the enabled default. Switch defaults in a batch update,
clearing the old default first. A nullable unique `default_slot` backs the validation.
@@ -91,11 +106,11 @@ for the selected kind:
| Kind | Default allowed names |
|---|---|
| SMTP / GMAIL_SMTP | `EMAIL_SMTP_USERNAME`, `EMAIL_SMTP_PASSWORD`, `EMAIL_GMAIL_USERNAME`, `EMAIL_GMAIL_APP_PASSWORD` |
| SMTP / GMAIL_SMTP | `EMAIL_SMTP_USERNAME`, `EMAIL_SMTP_PASSWORD`, `EMAIL_GMAIL_USERNAME`, `EMAIL_GMAIL_APP_PASSWORD`, `GMAIL_USERNAME`, `GMAIL_APP_PASSWORD` |
| SES | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` |
| POSTMARK | `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD` |
| SENDGRID | `EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY` |
| MAILGUN | `EMAIL_MAILGUN_API_KEY`, `EMAIL_MAILGUN_SIGNING_KEY` |
| POSTMARK | `POSTMARK_SERVER_TOKEN`, `POSTMARK_WEBHOOK_PASSWORD`; legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD` |
| SENDGRID | `SENDGRID_API_KEY`, `SENDGRID_WEBHOOK_PUBLIC_KEY`; legacy `EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY` |
| MAILGUN | `MAILGUN_API_KEY`, `MAILGUN_WEBHOOK_SIGNING_KEY`; legacy `EMAIL_MAILGUN_API_KEY`, `EMAIL_MAILGUN_SIGNING_KEY` |
| GMAIL_API | `EMAIL_GMAIL_CLIENT_ID`, `EMAIL_GMAIL_CLIENT_SECRET`, `EMAIL_GMAIL_REFRESH_TOKEN` |
Allowlisting does not automatically select a name. Empty SMTP references mean no auth;
@@ -106,8 +121,16 @@ resolved during an attempt and never retained, audited, returned or logged. The
provider record's display-only fields return only `Set` / `Not set`; checking presence
necessarily invokes the resolver too. Enqueue and internal queries do not resolve it.
POSTMARK, SENDGRID, MAILGUN and GMAIL_API have metadata but no transport in this part.
Their delivery attempts fail permanently with `not available until phase (b)`.
POSTMARK, SENDGRID and MAILGUN require a secret environment name. Mailgun also
requires a sending domain and US/EU region. Gmail SMTP requires both credential
names and STARTTLS. GMAIL_API is refused with
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
HTTP transports use fixed HTTPS endpoints, 10-second connect and 30-second request
timeouts, and no redirects. HTTP 429/5xx retry with optional Retry-After; account
and credential failures pause the provider. Tracking is explicitly controlled by
`trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c)
receiver idempotently when the provided Javalin middleware is present.
## Sending
@@ -127,8 +150,8 @@ Use `withHtmlBody` or `withTextBody` instead of Markdown, with optional explicit
alongside HTML. `withFrom`, `withReplyTo`, `withBcc`, `withProviderName`, `withSendAfter`,
and `withLocale` are supported. `sendingDomain`, when present, must match the sender's
domain. Addresses are parsed strictly; header newlines are refused. Markdown raw HTML
and unsafe URL schemes are disabled. `EmailRenderer` is the replacement seam for
part 2. Both alternatives are stored at enqueue time and reused on retries.
and unsafe URL schemes are disabled. Templates always use the validated template renderer. The ad-hoc renderer extension
remains compatible. Both alternatives are stored at enqueue time and reused on retries.
Results expose `messageId()`, `status()` (`QUEUED`, `DUPLICATE`, `SUPPRESSED`, `REFUSED`)
and `reasons()`. Idempotency is global within the outbox. A repeated key returns the
@@ -154,6 +177,151 @@ to NOTIFICATION and MARKETING. Expired rows are ignored. Partial suppression dro
only matching recipients and records them in `suppressedAddresses`; all-suppressed
messages remain visible with SUPPRESSED status and never consume an attempt.
## Templates, variables and branding
Create an `email_template`, then use **New Version** to copy the active or latest
version into a DRAFT. Edit its source, schema and synthetic sample values. **Activate
Version** validates syntax, samples, classifications, brand contrast and optional
MJML compilation, then retires the previous active version for that locale in one
transaction. ACTIVE and RETIRED versions are immutable. Template-specific
`editPermissionName` applies to CRUD and actions, in addition to the email permission;
even email administrators need that explicit grant. Trusted system writes may bypass it.
```java
EmailService.send(new EmailRequest()
.withTemplateName("order_ready")
.withTo("pat@example.com")
.withRecordVariable("order", "order", 123)
.withVariable("count", 3)
.withLocale("en"));
```
A schema is a JSON list, for example:
```json
[
{"name":"order","label":"Order","type":"RECORD","required":true},
{"name":"count","label":"Items","type":"NUMBER","required":true},
{"name":"link","type":"URL","classification":"PUBLIC"}
]
```
Types are STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL, LIST and RECORD. Optional
`table`/`field` bind a variable's classification to metadata. URL values must be
absolute HTTP(S) URLs. Use `{{name}}`, `{{order.number}}`, `{{name|fallback text}}`,
`{{#if count}}...{{/if}}`, and `{{#each items}}{{@index}}: {{this.name}}{{/each}}`.
`this` also supports scalar list elements. Empty/null/false/zero values are false.
Unknown variables, unsupported helpers, unclosed expressions and mismatched blocks
produce line-numbered errors. Records and lists allow dynamic child paths; declare
specific dotted entries when child types/requiredness matter. There is no raw output.
HTML values are escaped; Markdown values cannot inject Markdown syntax. Subject and
text contexts use plain values, with subject newlines refused.
Built-ins are `brand.*` (snake_case names such as `label`, `site_url`, `primary_color`),
`message.id` (the actual outbox primary key), `now` (UTC), and `unsubscribe_url`.
The latter is empty for TRANSACTIONAL/SECURITY and when no notification opt-out key
is configured. The requested locale resolves to an ACTIVE version, falling back to
`en`. The template supplies the category unless the request overrides it.
Brand precedence is request name, template brand, the configured app's brand,
default brand, then the built-in neutral brand. Markdown uses CommonMark tables and
autolinks; raw HTML is escaped. `[Label](https://example.com){.button}` creates a table
button. The 600px shell includes preheader, logo variants, footer, social links and
light/dark colors; marketing requires a postal address. Brand saves and activation
require 4.5:1 text and button contrast in both modes. Optional `darkPrimaryColor` and
`darkButtonTextColor` fall back to their light counterparts. Colors use `#RRGGBB`.
HTML versions retain authored HTML, with escaped variables and an opt-out footer
before `</body>`. For MJML configure `withMjmlCompiler(new CliMjmlCompiler(binaryPath))`
or another compiler implementation. Compilation uses stdin/stdout only, with a
20-second timeout; the stored `compiledHtml` is reused for sends. No runtime files,
source files or compiler temp files are written by this qbit.
The record Preview widget uses synthetic `sampleVariables`, sanitized inline light
(390px maximum) and dark (600px maximum) samples plus wrapping plain text. Next's
sanitizer removes iframes and style blocks, so this preview is not an exact mail-client
emulator. Draft MJML is compiled in memory for preview when a compiler is configured;
preview never stores the result or changes the version.
### Templates from another qbit
A qbit that sends its own mail ships the template with its code instead of asking an
administrator to create it. Describe it with `EmailTemplateDefinition` and call
`EmailTemplates.ensure` at startup or before first use:
```java
boolean created = EmailTemplates.ensure(new EmailTemplateDefinition()
.withName("order_ready")
.withLabel("Order ready")
.withCategory(EmailCategory.TRANSACTIONAL)
.withSubject("Order {{order_number}} is ready")
.withBody("Hi {{customer_name}}, order {{order_number}} is ready for pickup.")
.withVariable("order_number", "Order number", "STRING", true, "PUBLIC")
.withVariable("customer_name", "Customer name", "STRING", true, null)
.withSampleVariables("{\"order_number\":\"A-1\",\"customer_name\":\"Pat\"}")
.withOwnerQbit("orders")
.withEditPermissionName("orders.edit"));
```
`kind` defaults to MARKDOWN (MJML and HTML are also accepted), `withTextBody` is
optional, and `withVariablesSchema(json)` replaces `withVariable` when you already have
the JSON. When no template of that name exists, `ensure` validates the definition like
Activate Version (syntax, schema, a render with the samples, MJML compile), creates the
template and an ACTIVE version 1 as the qbit, and returns true. When one exists it does
nothing and returns false, so people's edits and later versions are never overwritten;
the unique name settles concurrent callers. An invalid definition throws
`IllegalArgumentException` with plain reasons and writes nothing.
## PHI and PII
`withRecordVariable` reads through `QueryAction` in the sender's QContext; it does not
open an authorized scope. Core password masking is preserved. RC.5 does not enforce
ordinary `FieldSecurityLock` during QueryAction, so the email record loader also
applies the field lock against the sender's security keys before copying any values.
Classification precedence is reflective core `AiDataClassifications.ofField`, config
`classificationOverrides`, schema declaration, then PUBLIC.
PII/PHI variables require **Approve For PHI** (`email.admin`) on the exact version.
Approval records actor/date and `approvedVersionId`; it freezes an approved draft.
A new sensitive version must be approved before activation. Activating a version
without its own approval clears the template's old approval. Approval of one locale
version does not approve another locale. PHI-approved templates cannot be MARKETING,
use provider open/click tracking, or use a provider without `phiAllowed`. Provider
attestation and tracking are checked again immediately before dispatch.
`email_secure_message` is created transactionally on its first send. It needs only
a required URL variable `link` and brand built-ins; use it to send a sign-in link
without putting patient data into email.
PII/PHI snapshot values become `hmac:v1:<keyId>:<base64url>`, or `changed:v1` without
a valid base64 `QQQ_AUDIT_HMAC_KEY`. Record tokens bind table, field and primary key;
context tokens bind `email`, template name, variable name and message id. Context
booleans and values with fewer than four distinct characters always use `changed:v1`.
`shred:v1` is reserved for future core wipe-rule record fields and is never produced
for context values. The classification and token helpers are explicit RC.7 stand-ins.
PHI-approved subjects and HTML/text bodies are AES-256-GCM encrypted as
`enc:v1:<base64url(iv|ciphertext|tag)>`, using SHA-256 of `QQQ_EMAIL_BODY_KEY`.
Missing keys refuse the send before an outbox insert. Only dispatch decrypts; query,
record and API displays show `[encrypted]`, while home shows `Secure message`.
Resend preserves ciphertext and policy; its token snapshot stays bound to the original
message, which the new message links to. Errors and logs omit provider diagnostics
and content. Keys resolve through `secretResolver`; no secret values are stored.
NOTIFICATION/MARKETING templates prepare footer links and headers using
`QQQ_EMAIL_UNSUBSCRIBE_KEY` and `publicBaseUrl`; marketing refuses without them.
Tokens bind the message, recipient-envelope hash and category. The receiving route
is implemented: GET only shows a light/dark confirmation page; explicit POST upserts
category suppression and records UNSUBSCRIBED. Invalid or account/security tokens
return 404. The legacy token covers every To/Cc/Bcc recipient, so use one mailbox
per opt-out message for individual preferences. Preferences UI remains deferred
until core #1162 ships in an RC.
The home uses viewer-scoped counts and queries, UTC hour/day charts, provider health
(admins), and 20 recent messages with masked recipients. No sample data or sampled
counts are used. Delivered/bounced/complained totals count recipient events and
use their occurred dates; sent/dead-letter totals count messages.
## Delivery and actions
Each claim inserts a unique `(message_id, attempt_number)` row and commits SENDING
@@ -191,16 +359,54 @@ Providers offer Send Test Email with a recipient input and a reported outcome.
`email_send_templated` is a hidden backend API process. Inputs include `templateName`,
`to`, `cc`, `bcc`, `replyTo`, `subject`, `markdownBody`, `htmlBody`, `textBody`,
`providerName`, `brandName`, `category`, `variablesJson`, `linkedTableName`,
`linkedRecordId`, and `idempotencyKey`. Ad-hoc content works now; template/brand/variable
inputs return REFUSED until part 2. Output includes `messageId`, `status`, and `reasons`.
`providerName`, `brandName`, `category`, `locale`, `variablesJson`, `recordVariablesJson`,
`linkedTableName`, `linkedRecordId`, and `idempotencyKey`. `variablesJson` is an object;
record bindings use `{"patient":{"table":"patient","id":123}}` in
`recordVariablesJson`. Output includes `messageId`, `status`, and `reasons`.
Hidden schedules are `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
Hidden schedules include `email_process_webhooks` (30 s), `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
recovery including disabled providers, and health probes), and `email_retention`
(daily). Retention clears final-message bodies after 30 days, preserving `bodySha256`;
message deletion is disabled by default. `finishedDate` records finalization independently
of enqueue and later retention updates.
## Delivery tracking
Apply the new `email_webhook_inbox` and `email_event` DDL plus indexes for an existing
phase-(a)/(b) database. Configure the authenticated HTTPS endpoints using
[the provider guide](docs/PROVIDERS.md#event-webhooks). Verification supports SNS
RSA v1/v2 with pinned topic and AWS certificate hosts, Postmark Basic auth,
SendGrid ECDSA and Mailgun HMAC with durable nonce replay protection.
The 1 MiB receiver persists before acknowledging. DB transactions serialize
processing across replicas and commit event rows, message state and suppression
atomically. Events in a payload are applied independently: matched events commit at
once, and only unmatched events (or events whose message is still SENDING) retry the
row with configured backoff. After six attempts they are skipped and the row finishes
PROCESSED with a note ("N events did not match a message"). Rows that cannot be parsed
or written fail after six attempts; **Reprocess** on FAILED inbox rows uses the
standard Review/Result flow. An event whose recipient is not on the matched message
is ignored, and an SES bounce or complaint without its recipient list is skipped.
SNS notifications older than an hour or more than five minutes ahead are refused, and
certificate fetch failures are cached for five minutes per URL. Disabled tracking is
ignored. Per-event recipient keys deduplicate
overlapping batches. All new service state is in the DB; certificate caches are in
memory. No runtime filesystem or new dependencies are used.
Hard bounce/complaint suppress ALL; provider unsubscribe suppresses the message's
opt-out category. Message status never regresses from COMPLAINED to BOUNCED or
DELIVERED. Optional `email.complaint` alerts warn after more than three complaints
in 24 hours, with daily deduplication. Provider details mask addresses and are capped
at 500 characters; raw authenticated inbox payloads are sensitive DB data.
NOTIFICATION and MARKETING messages with unsubscribe configured require exactly one
To recipient and no Cc/Bcc; send one message per recipient. The token binds that
mailbox, and an unsubscribe POST suppresses only that address for the message's
category. Multi-recipient requests are refused before suppression filtering.
TRANSACTIONAL and SECURITY messages, and NOTIFICATION messages without unsubscribe
configured, may still have multiple recipients. Existing single-recipient links
remain valid; old multi-recipient links are rejected.
## Core messaging bridge
The producer adds messaging provider `qbitEmail` (configurable), type `QBIT_EMAIL`.
@@ -218,6 +424,18 @@ The bridge recursively maps Party/MultiParty TO, CC, BCC, FROM and REPLY_TO role
TEXT/HTML alternatives. REFUSED throws `QException`; successful calls acknowledge
queueing, not delivery. The built-in EMAIL and SES types are unchanged.
For schema-aware core bridge calls, use the typed extension:
```java
new SendMessageAction().execute(new EmailTemplatedMessageInput()
.withEmailRequest(new EmailRequest().withTemplateName("email_secure_message")
.withTo("pat@example.com").withVariable("link", "https://app.example.com/inbox"))
.withMessagingProviderName("qbitEmail"));
```
Ordinary core `SendMessageInput` remains ad-hoc and cannot infer classifications
from arbitrary pre-rendered text. Use the typed extension for classified variables.
## Configuration
All settings have fluent `withX` setters and getters.
@@ -226,7 +444,10 @@ All settings have fluent `withX` setters and getters.
|---|---|
| `backendName` | Required existing backend |
| `schedulerName` | Optional existing scheduler; required for automatic dispatch |
| `publicBaseUrl` | Reserved for later unsubscribe links |
| `publicBaseUrl` | Public app URL for signed unsubscribe links |
| `webhookBasePath` | `/qqq/hooks/email`; absolute path outside `/qqq/v1` |
| `mjmlCompiler` | Optional `MjmlCompilerInterface`; required for MJML activation |
| `classificationOverrides` | Map of `table.field` to `EmailClassifications.Level` for RC.5 |
| `defaultProviderName` | Optional named provider override; otherwise enabled default row |
| `bridgeProviderName` | `qbitEmail` |
| `secretResolver`, `allowedEnvNames` | `System::getenv`, empty additional allowlist |
@@ -250,9 +471,10 @@ modules to these declarations.
```
Windows can use `./mvnw.cmd -o -q verify`. No new dependencies are required. Tests use
H2, GreenMail and a fake SES client, including transaction rollback, concurrent claims,
H2, GreenMail, a fake SES client and local HTTP servers, including transaction rollback, concurrent claims,
concurrent idempotency, shared rate budgets, health recovery and process screens.
See [implementation notes](docs/PART1-NOTES.md) for design/API accommodations.
The tests also exercise template lifecycle, parent-scoped possible values, rendering,
PHI canary egress, encryption, preview widgets and home data.
## License
+90 -17
View File
@@ -1,6 +1,6 @@
# qbit-email design
**Status:** spec. Phases (a) to (d) build outbound; phase (e) is inbound (section 12, design only).
**Status:** phases (a), (b) and (c) implemented, except Preferences (8.3), deferred until core #1162 ships in an RC. Phase (d) consumer migrations and phase (e) inbound remain specification.
**Brief:** owner decisions of 2026-10-13 (providers, outbound first, both template kinds).
**Owner decisions (2026-10-12): all recommendations accepted.** Every "Recommended" choice and
every decision below stands; it is adjusted once it can be seen working.
@@ -20,7 +20,8 @@ unsubscribe and PHI rules.
| D7 | Hard bounces and complaints suppress the address. One-click unsubscribe (RFC 8058) records a suppression for the message's category, and users see it as a Preferences section (core #1162). |
| D8 | PHI-safe by default: PII/PHI-classified variables are refused unless the template is `phi_approved`. The recommended PHI pattern is a "secure message" (a sign-in link, no PHI in the body). |
| D9 | Gmail: SMTP with an app password in phase (b), as a preset of the SMTP transport. The Gmail API with OAuth is specified (section 3.6) and built only on request. |
| D10 | MJML is compiled when a version is activated, by a pluggable compiler (default: the `mjml` CLI when configured). Sends never need the compiler; HTML templates need nothing. |
| D10 | MJML is compiled when a version is activated, by a pluggable compiler (default: the `mjml` CLI when configured, through stdin and stdout). Sends never need the compiler; HTML templates need nothing. |
| D11 | Owner rule (2026-10-13): no files or filesystem, only the database and S3. Attachments, rendered bodies and (phase e) inbound mail live in the database or in object storage (qbit-object-storage), never on local disk; the services are stateless, so any replica can send, receive a webhook or render a preview. |
## 1. Flow
@@ -91,7 +92,7 @@ template may set `edit_permission_name` to restrict who edits it (e.g. marketing
TRANSACTIONAL unless configured) and enqueues it. It throws on `REFUSED`, so callers still see
failures; delivery is then asynchronous.
- Attachments are stored through qbit-object-storage when it is installed, else refused with a
plain message (no blobs in the outbox).
plain message (no blobs in the outbox, nothing on local disk: D11).
- Callers (qbit-alerts `EmailChannel` and `NotificationEmailer`, qbit-marketing
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailService`
in phase (d) for templates, categories, idempotency and linked records.
@@ -204,7 +205,9 @@ error) and `email_event` (section 6).
content), Cancel (queued or waiting), Send Now (waiting). Standard streamed-ETL Review and Result
screens.
- **Retention:** a daily job clears bodies after `bodyRetentionDays` and deletes final messages
older than `messageRetentionDays` (0 = keep forever; kof22 keeps).
older than `messageRetentionDays` (0 = keep forever; kof22 keeps). NOTIFICATION and MARKETING
messages are never deleted, because their unsubscribe links resolve through the message row
(8.2); at `messageRetentionDays` retention only clears their bodies and variable snapshot.
## 5. Templates and branding
@@ -254,11 +257,39 @@ the exact version it used.
version, else the default locale's. One locale (`en`) ships.
### 5.3 Preview
**Part 2 status:** standard record widget implemented. Next's sanitizer removes
iframes and style blocks, so it uses sanitized inline light (390px maximum) and dark
(600px maximum) samples, with wrapping plain text. Draft MJML previews compile in memory
with the configured compiler; stored versions are unchanged. TODO: endpoint arrives with RC.6.
Each version record shows a **Preview** widget: the rendered email with the sample variables in a
sandboxed iframe (`srcdoc`, scripts disabled), Light/Dark and 390/600 px toggles, and the plain
text. It is served through the UI API (section 10): `POST /qqq/v1/qbit/email/preview` (a version
id, or unsaved source, plus variables) returns subject, html, text and warnings.
### 5.4 Part 2 implementation choices
- Approval records `approved_version_id` on the template. An approved DRAFT freezes;
approve a new sensitive version before activation. Activating a different,
unapproved version clears approval. Approval is specific to one locale version.
- `EmailBuiltIns.ensure()` seeds `email_secure_message` on its first send, using the
database unique name and a transaction, with no static bootstrap cache.
- `dark_primary_color` and `dark_button_text_color` optionally override their light
counterparts; both button pairs are contrast-checked. Brand colors use #RRGGBB.
- `EmailTemplatedMessageInput` extends core SendMessageInput for schema variables and
record references. Ordinary core input remains compatible ad-hoc content.
- RC.5 QueryAction masks passwords but does not apply ordinary FieldSecurityLock;
record-variable loading additionally applies that lock to sender session keys.
- The real outbox id is bound during the enqueue transaction, before commit. Failed
validation persists no message. Resends preserve encrypted content and its original
token snapshot, linking to the original message for the token binding.
- The home follows the newer Alerts pattern: standard QQQ chart widgets plus inline,
theme-neutral cards. All queries/counts use the viewer's session. Bounce/complaint
dates are populated by phase-(c) events; home totals count those recipient events.
- Part 2 signs opt-out links and prepares headers; phase (c) now serves the receiver. Without its key/base URL, notification omits opt-out links and
headers, while marketing refuses as specified in 8.2. Do not use opt-out delivery
without a receiving handler. The token's address hash binds the single recipient mailbox.
## 6. Delivery tracking
### 6.1 Inbound route
@@ -282,6 +313,32 @@ DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED), bounce_
UNDETERMINED), recipient, occurred_date, detail (the provider's reason, truncated; never body
content), url (clicks only). Opens and clicks are stored only when the provider row enables them.
### 6.3 Phase (c) implementation
- Inbox states: PENDING, PROCESSED, FAILED, IGNORED. Retry after configured backoff,
FAILED after six attempts, and a standard Reprocess action resets FAILED to PENDING.
- Events in one payload are applied independently. Only unmatched events (or events whose
message is still SENDING) defer the row; on the sixth attempt they are skipped and the row is
PROCESSED with the note "N events did not match a message". Suppression applies only to a
recipient in the matched message's to, cc or bcc; an SES bounce or complaint without its
recipient list is skipped, never widened to all destinations. SNS notifications must carry a
Timestamp no older than 1 hour and no more than 5 minutes ahead, and certificate fetch
failures are cached for 5 minutes per URL.
- A short DB transaction locks the common provider gate then the actual provider;
it rechecks the observed inbox attempt and commits all changes together. No worker
filesystem, extra claim table, in-process scheduler or network operation is needed
during processing. A crash rolls back the transaction and releases the claim.
- `email_event.event_key` provides unique recipient/event deduplication across
overlapping batches, alongside inbox uniqueness on provider/event and provider/nonce.
- Message correlation always stays within the authenticated provider. Unknown message
ids retry (including callbacks before SENT); unsupported event kinds are ignored.
- Opens/clicks require current provider flags and are also dropped for PHI-approved
messages. Event details mask addresses before truncation to 500 characters.
- The unsubscribe signer binds one mailbox. A valid POST suppresses only that address
for MARKETING/NOTIFICATION; the page never displays addresses. Legacy links remain
valid only for messages with one To recipient and no Cc/Bcc.
- PostgreSQL and H2 definitions, crypto fixtures and real random-port Javalin tests
are included. Certificate caching is memory-only. Setup is in `PROVIDERS.md`.
## 7. PHI and PII
### 7.1 Classification
@@ -304,9 +361,20 @@ schema.
### 7.3 Redaction and storage
Following core `docs/SECURED-DATA.md` (section 15, QRun-IO/qqq#1161):
- `variables_snapshot`: PII/PHI values become `hmac:v1:<keyId>:<b64url>` (HMAC-SHA256 with the
instance audit key `QQQ_AUDIT_HMAC_KEY`, over template␟variable␟message␟canonical value), or
`changed:v1` with no key; low-entropy values (booleans, small enums) are shredded (`shred:v1`).
- `variables_snapshot`: PII/PHI values become tokens in the format of core
`ClassifiedValueTokens` (package `...core.actions.permissions.fieldaccess`, SECURED-DATA §14.5):
`hmac:v1:<keyId>:<b64url(HMAC-SHA256(key, m))>`, with `m` joined by `` and the canonical
value last, and the instance key from `QQQ_AUDIT_HMAC_KEY` (base64).
- **Record-bound variables** use the record binding, `m = table␟field␟pk␟canonical(value)`
(`forRecordField`), so the token matches the audit's and a field under a wipe rule gets
`shred:v1` with the same per-record-field key: a wipe also shreds the outbox snapshot.
- **Other values** use a domain-prefixed binding,
`m = "email"␟template␟variable␟messageId␟canonical(value)` (`forContext`), so they never
collide with a record binding. They get `hmac:v1` only; a low-entropy value (booleans, values
with under 4 distinct characters) gets `changed:v1`, because there is no record key to shred.
- No key configured: `changed:v1`.
- Until core ships the helper, the qbit carries `EmailValueTokens`, marked
`STAND-IN(core=#1161-impl, removeBy=RC.7)`, with the same names and output.
- The subject and bodies of `phi_approved` messages are stored AES-256-GCM encrypted with a key
from `QQQ_EMAIL_BODY_KEY` (no key: `phi_approved` sends are refused) and cleared after
`bodyRetentionDays` once final.
@@ -330,6 +398,7 @@ MANUAL), source_message_id, source_event_id, note, created_date, expires_date (o
respects HARD_BOUNCE and COMPLAINT.
### 8.2 One-click unsubscribe (RFC 8058, RFC 2369)
- NOTIFICATION or MARKETING messages carrying an unsubscribe link/header must have exactly one To recipient and no Cc/Bcc; send one message per recipient.
- MARKETING and NOTIFICATION messages carry
`List-Unsubscribe: <https://{publicBaseUrl}/qqq/hooks/email/unsubscribe/{token}>` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and the footer links to the same URL.
@@ -338,17 +407,20 @@ MANUAL), source_message_id, source_event_id, note, created_date, expires_date (o
MARKETING sends are refused. Tokens do not expire (RFC 8058 expects the link to keep working).
- `POST` with `List-Unsubscribe=One-Click` records the suppression and returns 200. `GET` returns a
minimal page with one "Unsubscribe" button that POSTs, because mail scanners prefetch GETs; then
"You are unsubscribed from {category}", with a link to Preferences.
"You are unsubscribed from {category}". A Preferences link awaits core #1162.
- An `UNSUBSCRIBED` event is recorded on the message.
### 8.3 Preferences (core #1162)
The qbit registers a Preferences section `email` ("Email") whose provider
**Deferred: core #1162 is not in an RC.** The planned qbit registers a Preferences section `email` ("Email") whose provider
(`EmailPreferenceProvider implements UserPreferenceProviderInterface`) maps the signed-in user's
address to `email_suppression` rows: one toggle per opt-out-able category (NOTIFICATION,
address (`UserPreferenceRequestContext.getUserEmailAddress()`, never an address from the request)
to `email_suppression` rows: one toggle per opt-out-able category (NOTIFICATION,
MARKETING) and read-only rows saying that account and security mail is always sent. Qbits with
finer settings keep their own sections: qbit-alerts' "Notifications" decides which alerts are
emailed; this section decides whether NOTIFICATION mail reaches the address at all. It is built
once #1162 is in an RC; one-click unsubscribe does not depend on it.
once #1162 is in an RC; one-click unsubscribe does not depend on it. With no address on the
account, the rows are read-only ("No email address on your account") and writes are refused. The
section's description explains the split with qbit-alerts' Notifications section.
## 9. Integrations
- **Alerts (core `QAlerts`, #1108):** called reflectively when present (section 4.2).
@@ -411,10 +483,11 @@ once #1162 is in an RC; one-click unsubscribe does not depend on it.
| `email_webhook_inbox` | relation (on the provider) | the qbit |
| `email_inbound`, `email_inbound_attachment` | phase (e) | the qbit |
Postgres DDL in `docs/ddl/email-postgres.sql` (phase a).
Postgres DDL in `docs/ddl/email-postgres.sql` (phases a-c).
## 14. Configuration (`EmailQBitConfig`)
backendName (required), schedulerName (required to send), publicBaseUrl (unsubscribe links),
backendName (required), schedulerName (required for automatic processing), publicBaseUrl (unsubscribe links),
webhookBasePath (`/qqq/hooks/email`, shared by tracking and unsubscribe),
defaultProviderName, bridgeProviderName (`qbitEmail`), secretResolver, allowedEnvNames,
dispatchIntervalSeconds (15), batchSize (100), maxAttempts (6), retryMinutes, leaseSeconds (120),
bodyRetentionDays (30), messageRetentionDays (0), mjmlCompiler, classificationOverrides, appName
@@ -423,14 +496,14 @@ and appLabel, securityFields and recordSecurityLocks (multi-tenant hosts).
## 15. Phases and acceptance
| Phase | Scope | Acceptance (tests) |
|---|---|---|
| (a) | tables, outbox and dispatcher, SMTP and SES, Markdown templates and versions, brands and the layout, preview, EmailService, the process, the core bridge, PHI refusal and redaction, the app home | GreenMail SMTP sink end to end; SES through a mocked `SesV2Client`; the claim race (two runners, each message sent once); retry, dead letter, idempotency; rendering (variables, escaping, `#if`/`#each`, plain text, dark-mode contrast); PHI refusal; snapshot redaction; the canary test; producer standards (names, nav, permissions) |
| (b) | Postmark, SendGrid, Mailgun, Gmail SMTP; MJML and HTML templates | HTTP mocks per provider (success, 4xx permanent, 429/5xx transient); MJML through a fake compiler; the Gmail preset |
| (c) | webhook route and inbox, events, suppression, unsubscribe, Preferences section | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
| (a) | tables, outbox and dispatcher, SMTP and SES, Markdown/HTML/MJML templates and versions, brands and the layout, preview, EmailService, the process, the core bridge, PHI refusal and redaction, the app home | GreenMail SMTP sink end to end; SES through a mocked `SesV2Client`; the claim race (two runners, each message sent once); retry, dead letter, idempotency; rendering (variables, escaping, `#if`/`#each`, plain text, dark-mode contrast); PHI refusal; snapshot redaction; the canary test; producer standards (names, nav, permissions) |
| (b) | Postmark, SendGrid, Mailgun, Gmail SMTP | HTTP mocks per provider (success, 4xx permanent, 429/5xx transient); the Gmail preset. HTML/MJML moved into part (a), part 2 at owner request. |
| (c) | webhook route and inbox, events, suppression, unsubscribe (Preferences deferred) | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
| (d) | consumer migrations | each consumer's own tests; a contract test of `EmailService` |
| (e) | inbound | design only for now |
Dependencies: phase (a) builds on QQQ 4.1.0-RC.5 with the classification stand-in, and moves to
RC.6 for `DataClassification` and Preferences.
later RCs for `DataClassification` and Preferences when their APIs are released.
## 16. Owner steps (outside code)
- **PMG (kof22):** none; the relay already accepts the cluster.
+265
View File
@@ -0,0 +1,265 @@
# Email providers
Create an enabled `email_provider` row and choose exactly one enabled default.
Provider edits and Send Test Email require `email.admin`. Test sends queue through
the outbox and obey the same rate limits as application messages.
Field names here are database columns. QQQ record/API names are camelCase:
`secret_env_name` is `secretEnvName`, for example. All kinds use `name`, `label`,
`kind`, `enabled`, `is_default`, `from_address`, optional `from_name` / `reply_to`,
and optional `max_per_minute` / `max_per_day`. When set, `sending_domain` must
match the From address domain exactly.
## Credentials and delivery
Credential fields contain environment variable **names**, never values. Names
must match `^[A-Z_][A-Z0-9_]{0,99}$` and the effective allowlist. The defaults
below supplement `EmailQBitConfig.allowedEnvNames`; explicitly allow custom names.
Allowlisting does not automatically select a name for a row.
| Kind | `username_env_name` | `secret_env_name` | `webhook_secret_env_name` |
|---|---|---|---|
| SMTP | `EMAIL_SMTP_USERNAME` (optional) | `EMAIL_SMTP_PASSWORD` | Not used |
| SES | `AWS_ACCESS_KEY_ID` (optional) | `AWS_SECRET_ACCESS_KEY` (optional pair) | Not used; `sns_topic_arn` is a plain ARN |
| POSTMARK | Not used | `POSTMARK_SERVER_TOKEN` | `POSTMARK_WEBHOOK_PASSWORD` |
| SENDGRID | Not used | `SENDGRID_API_KEY` | `SENDGRID_WEBHOOK_PUBLIC_KEY` |
| MAILGUN | Not used | `MAILGUN_API_KEY` | `MAILGUN_WEBHOOK_SIGNING_KEY` |
| GMAIL_SMTP | `GMAIL_USERNAME` | `GMAIL_APP_PASSWORD` | Not used |
Legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD`,
`EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY`, `EMAIL_MAILGUN_API_KEY`,
`EMAIL_MAILGUN_SIGNING_KEY`, `EMAIL_GMAIL_USERNAME` and `EMAIL_GMAIL_APP_PASSWORD`
remain allowlisted for their kinds. SES also allowlists `AWS_SESSION_TOKEN` for
the default credential chain; explicit row credentials are an access/secret pair.
Values resolve through `secretResolver` (default `System::getenv`) for each send
attempt. Rotation needs no service restart. Provider record presence indicators
also resolve values, returning only Set / Not set; enqueue and internal queries
do not resolve credentials. Values and provider response diagnostics are never
logged, stored in errors, returned in exceptions or cached by these transports.
HTTP uses fixed HTTPS endpoints, a 10-second connect timeout, a 30-second request
timeout, no redirects and no client retries. The outbox owns retries. MIME and
multipart bodies are built entirely in memory; services use no filesystem storage.
HTTP 429 and 5xx are transient. Retry-After accepts seconds or an HTTP date;
the dispatcher caps explicit delays at one hour. Authentication/account failures
mark the provider UNHEALTHY, stop its remaining deliveries for the dispatch pass,
and retain messages for retry without consuming their failure budget. Permanent
message errors become DEAD_LETTER. Exceptions use fixed text and numeric codes;
stored errors use fixed messages, never response bodies.
Acceptance means SENT, not delivered. Delivery is at least once: a crash after
acceptance can cause a duplicate. Limits count attempts across replicas in rolling
minute and 24-hour windows, not recipients or successful deliveries. Leave room
for retries, recipient quotas and other applications using the same account.
## SMTP
Set `kind=SMTP`, `host`, `port`, and `security=NONE`, `STARTTLS`, or `TLS`. For
authenticated SMTP, set both credential names; for an unauthenticated relay,
leave both empty. Authentication requires encryption except for loopback or an
explicit host `allowPlaintextSmtpAuth` configuration.
Configure the relay's allowed sender domains and route. For public delivery,
verify the domain with the operator, publish its DKIM and SPF records, and align
the From domain with DMARC. Update the existing SPF policy rather than adding a
second SPF record. Set limits below the relay's quota. SMTP has 10-second connect
and 15-second read/write timeouts; there is no provider event webhook here.
## Amazon SES
Set `kind=SES`, `region`, and a verified `from_address`. Optionally set
`sending_domain`, `configuration_set` and `sns_topic_arn`. Leave credential names
empty for the AWS default chain (workload/instance role or AWS environment);
otherwise supply both access-key and secret-key environment names.
Verify the sender identity in the sending region and publish SES's DKIM records.
Configure SPF for a custom MAIL FROM domain if used. Request production access
when leaving the sandbox. Configure the configuration set's SNS event destination
and put the topic ARN in `sns_topic_arn`, not a secret field.
Sending quotas are regional and account-specific, with sandbox, recipient and
rate restrictions. Set both rate fields to fit those quotas. SES sends raw MIME
with explicit envelope destinations and strips Bcc from its MIME headers.
## Postmark
Set `kind=POSTMARK`, `secret_env_name=POSTMARK_SERVER_TOKEN`, a verified
`from_address`, and optionally `sending_domain`. Use a server token, not an
account token. `message_stream` defaults to `outbound`; another transactional or
broadcast stream must exist on that server. `track_opens` and `track_clicks`
default to false; click tracking maps to HtmlAndText or None.
Verify the domain or sender signature, publish Postmark's DKIM and recommended
Return-Path/SPF DNS records, obtain account approval and create the desired
message stream. The endpoint is `https://api.postmarkapp.com/email`;
the returned `MessageID` is retained.
| Signal | Outcome |
|---|---|
| ErrorCode 10 | Provider failure: bad token |
| ErrorCode 300 / 406 | Permanent: invalid request / inactive recipient |
| ErrorCode 400/401, 405, 412, 413, 422 | Provider failure: signature, sending approval or server/account configuration |
| ErrorCode 1235/1236 / 1480 | Provider failure: stream / IP configuration |
| HTTP 401/403 | Provider failure |
| HTTP 429 / 5xx | Transient; takes precedence over a body code |
| Other request errors | Permanent; malformed successful responses are transient |
API ErrorCode 413 is an account problem; HTTP 413 is a payload-size error.
Check plan, recipient and payload limits before setting the rate fields. The
single-message API documents a 10 MB message limit. See
[Postmark API errors and limits](https://postmarkapp.com/developer/api/overview).
## SendGrid
Set `kind=SENDGRID`, `secret_env_name=SENDGRID_API_KEY`, a verified `from_address`
and optionally `sending_domain`. Create a key with Mail Send permission.
Authenticate the domain and publish SendGrid's DKIM/SPF DNS records; single-sender
verification can be used for initial tests. Confirm the account can send.
`track_opens` controls open tracking; `track_clicks` controls HTML and plain-text
click tracking. Explicit false values override account tracking defaults.
The global endpoint is `https://api.sendgrid.com/v3/mail/send`; only HTTP 202
counts as success and `X-Message-Id` becomes the provider message ID.
One personalization contains To/Cc/Bcc with display names. `custom_args` carries
`qqq_email_id`. Reserved headers are filtered case-insensitively: `x-sg-id`,
`x-sg-eid`, `received`, `dkim-signature`, `Content-Type`, `Content-Transfer-Encoding`,
`To`, `From`, `Subject`, `Reply-To`, `CC`, `BCC`. `List-Unsubscribe` and
`List-Unsubscribe-Post` are retained.
HTTP 401/403 are provider failures, 400/413 permanent, and 429/5xx transient.
Set rate limits for the account's plan/API quota. Payload and recipient limits
apply separately from outbox attempt limits. See
[SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
## Mailgun
Set `kind=MAILGUN`, `secret_env_name=MAILGUN_API_KEY`, `sending_domain` (required,
a DNS name without a scheme/path), and `region=US` or `EU` (required).
The From address domain must match the sending domain.
Add and verify the domain in that region. Publish Mailgun's SPF/DKIM records and
its requested tracking DNS records if enabling tracking. Use an API key allowed
to send for that domain. Sandbox domains restrict recipients; check production
status and plan/rate quotas before choosing `max_per_minute` / `max_per_day`.
Those fields do not enforce MIME-size or recipient limits.
The endpoint is `https://api.mailgun.net/v3/{sending_domain}/messages.mime`, or
`https://api.eu.mailgun.net/v3/{sending_domain}/messages.mime` for EU. Basic auth
uses `api:<resolved key>`. Multipart includes all To/Cc/Bcc mailboxes as envelope
destinations, MIME `message` with Bcc stripped, `o:tracking-opens`,
`o:tracking-clicks` (explicit yes/no from the row), and `v:qqq_email_id`.
The response `id` is stored without surrounding angle brackets.
HTTP 401/403 are provider failures, 400 permanent, and 429/5xx transient.
## Gmail SMTP
| Field | Value |
|---|---|
| `kind` | `GMAIL_SMTP` |
| `username_env_name` | `GMAIL_USERNAME`: value is the full account email address |
| `secret_env_name` | `GMAIL_APP_PASSWORD`: value is the generated app password |
| `security` | `STARTTLS` |
| `from_address` | Account address or a permitted configured alias |
| `max_per_day` | At most about 500 personal / 2000 paid Workspace; lower with headroom |
The preset always uses `smtp.gmail.com:587` with required STARTTLS, ignoring
custom host/port values. Both env names are required; other security values are
refused even if plaintext auth is enabled for other relays. An omitted security
value uses the preset at the settings layer, but provider forms default to NONE,
so select STARTTLS when creating the row.
Enable 2-step verification and generate an app password for the service; do not
use the normal account password. Account/admin security policy may disable app
passwords. A Google password change revokes existing app passwords, requiring a
new environment value. See
[Google app password setup](https://support.google.com/accounts/answer/185833).
Google manages domain authentication for personal gmail.com senders. For
Workspace, verify the domain and configure Google's SPF/DKIM records and DMARC.
Trials, new accounts, SMTP recipient quotas and other account users can reduce
available capacity. The 500/2000 limit is an upper planning bound, not guaranteed
throughput. See [Workspace limits](https://support.google.com/a/answer/166852)
and [personal Gmail limits](https://support.google.com/mail/answer/22839).
`GMAIL_API` is refused with:
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
It would require a Google Cloud project, OAuth consent, client credentials and
refresh-token management. It adds no needed system-mail capability over the app
password preset in this phase and is built only on request (DESIGN 3.6).
## Event webhooks
Phase (c) installs authenticated provider receivers when the host includes Javalin.
Use `https://<app>{base}/{kind}/{providerName}`, where `base` is
`EmailQBitConfig.webhookBasePath` (default `/qqq/hooks/email`), `kind` is `ses`,
`postmark`, `sendgrid` or `mailgun`, and `providerName` is the row's unique name.
The row must be enabled and its kind must match. The configurable base must start
with `/`, have no trailing slash, and stay outside `/qqq/v1`.
| Provider | Setup and events |
|---|---|
| SES | Send configuration-set Delivery, Bounce, Complaint, Reject and DeliveryDelay events to SNS; enable Open/Click only when desired. Set `sns_topic_arn` to that exact topic. Subscribe the HTTPS `/ses/{providerName}` URL with raw delivery **off**, so SNS sends its signed JSON envelope. No webhook secret env is used. Verified subscription confirmations are fetched automatically and retained as IGNORED inbox rows. |
| Postmark | Configure server/message-stream webhooks for Delivery, Bounce, SpamComplaint and SubscriptionChange; optionally Open and Click. Use HTTP Basic username **`postmark`** and the password resolved from `webhook_secret_env_name` (normally `POSTMARK_WEBHOOK_PASSWORD`). Configure those credentials in Postmark's webhook settings/URL; keep the stored provider row limited to the environment name. |
| SendGrid | Enable the Signed Event Webhook for delivered, bounce, dropped, deferred, spamreport and unsubscribe (group_unsubscribe is also accepted); optionally open/click. Store the base64 DER verification public key in `SENDGRID_WEBHOOK_PUBLIC_KEY`, referenced by `webhook_secret_env_name`. Preserve both Twilio signature/timestamp headers through the proxy. Batches are authenticated over timestamp plus the unchanged raw body. |
| Mailgun | Configure regional domain webhooks for delivered, permanent/temporary failed, complained and unsubscribed; optionally opened/clicked. Set `webhook_secret_env_name=MAILGUN_WEBHOOK_SIGNING_KEY` to the account's HTTP webhook signing key, **not** its sending API key. Preserve the JSON `signature` object and `event-data`. |
| SMTP / Gmail SMTP | No native event receiver. One-click unsubscribe remains available for opt-out mail. |
SNS accepts SignatureVersion 1 and 2, pins TopicArn, and fetches certificates only
from HTTPS `sns.<region>.amazonaws.com` or `.amazonaws.com.cn` hosts with `.pem`
paths. Certificates are cached only in memory; redirects are disabled. Confirmation
URLs use the same HTTPS host restriction. Canonicalization follows
[AWS's signature rules](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-verify-message-signature.html).
SNS retries can be old, so freshness is enforced only for SendGrid and Mailgun:
timestamps must be within five minutes in either direction. Keep host clocks synchronized.
Postmark authenticates the request with Basic auth and supplies no signed timestamp.
Requests are capped at 1 MiB, including chunked requests. Responses are JSON:
200 `received` or `duplicate`, 401 invalid verification/Mailgun nonce replay,
404 missing/disabled/mismatched provider, 413 oversized input, and 503 unavailable
verification configuration, SNS fetch or database. No request bodies, recipient
addresses or credential values are logged. Raw payloads are stored in the DB inbox
as sensitive data, readable with `email.view`.
The hidden `email_process_webhooks` schedule runs every 30 seconds. One bounded
transaction applies each inbox row, including events, message state and suppressions.
The common provider lock serializes suppression upserts across replicas/providers;
no network I/O occurs under that lock. Failed attempts back off with `retryMinutes`
and become FAILED after six attempts. Use the inbox's **Reprocess** Review/Result
action (`email.manage`) after resolving the cause. Unmatched messages retry, including
a webhook racing the transport's SENT commit. Unknown/disabled tracking events are
IGNORED. Per-event recipient keys prevent overlapping SendGrid batches from duplicating
history. Mailgun's `(provider_id, nonce)` uniqueness is distinct from event deduplication:
a stored nonce is refused with 401 even if its event is already known.
Hard bounces and complaints upsert ALL suppression; complaints take precedence over
hard-bounce reasons. Unsubscribe events suppress only MARKETING or NOTIFICATION for
the matched message. Delivery advances SENT to DELIVERED; hard bounce and complaint
advance delivery state without reversing a later complaint. Opens/clicks require the
corresponding provider flag and are never retained for PHI-approved messages.
Provider reason text is address-masked and limited to 500 characters; only clicks
retain URLs. More than three complaints in 24 hours raises optional WARNING alert
`email.complaint`, deduplicated per UTC day by core's alert sink.
## One-click unsubscribe
Set `publicBaseUrl` to the public HTTPS app origin and supply
`QQQ_EMAIL_UNSUBSCRIBE_KEY` through the secret resolver. The configured webhook base
also controls newly signed unsubscribe URLs. Keep the key and published path stable:
tokens have no expiry, but cannot resolve after their message row is deleted.
GET `{base}/unsubscribe/{token}` displays a self-contained, escaped light/dark form
and never changes state. POST `List-Unsubscribe=One-Click` returns an empty 200;
the page's form POST returns confirmation HTML. Both atomically upsert category
suppression and record an UNSUBSCRIBED event. Repeated POSTs do not duplicate events.
Invalid tokens return a generic 404. TRANSACTIONAL and SECURITY tokens are neither
issued nor accepted.
The existing v1 token binds the message, category and **complete To/Cc/Bcc envelope
hash**, rather than one mailbox. Following that link opts out every mailbox in the
bound envelope for that category; the page reveals none of those addresses. Send
one recipient per opt-out message when individual preference control is needed.
Preferences UI remains deferred until core #1162 ships in an RC.
+48 -1
View File
@@ -1,4 +1,4 @@
-- Phase (a), part 1. Apply before producing the qbit. Host security columns must be added by the host.
-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host.
CREATE TABLE email_provider (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -58,6 +58,8 @@ CREATE TABLE email_message (
html_body TEXT,
text_body TEXT,
body_sha256 VARCHAR(500),
phi_approved BOOLEAN,
unsubscribe_url TEXT,
idempotency_key VARCHAR(500),
provider_id INTEGER,
provider_message_id VARCHAR(500),
@@ -68,6 +70,8 @@ CREATE TABLE email_message (
finished_date TIMESTAMP,
sent_date TIMESTAMP,
delivered_date TIMESTAMP,
bounced_date TIMESTAMP,
complained_date TIMESTAMP,
dead_letter_date TIMESTAMP,
linked_table_name VARCHAR(500),
linked_record_id VARCHAR(500),
@@ -105,6 +109,8 @@ CREATE TABLE email_template (
kind VARCHAR(500),
brand_id INTEGER,
active_version_id INTEGER,
approved_version_id INTEGER,
version_lock INTEGER,
phi_approved BOOLEAN,
phi_approved_by VARCHAR(500),
phi_approved_date TIMESTAMP,
@@ -148,6 +154,8 @@ CREATE TABLE email_brand (
background_color VARCHAR(500),
dark_background_color VARCHAR(500),
dark_text_color VARCHAR(500),
dark_primary_color VARCHAR(500),
dark_button_text_color VARCHAR(500),
button_text_color VARCHAR(500),
footer_markdown TEXT,
postal_address TEXT,
@@ -176,3 +184,42 @@ CREATE TABLE email_suppression (
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
CREATE TABLE email_webhook_inbox (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
provider_id INTEGER NOT NULL,
kind VARCHAR(500) NOT NULL,
external_event_id VARCHAR(500) NOT NULL,
nonce VARCHAR(500),
status VARCHAR(500) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_date TIMESTAMP,
last_error VARCHAR(500),
payload_json TEXT,
payload_sha256 VARCHAR(500),
received_date TIMESTAMP,
processed_date TIMESTAMP,
UNIQUE (provider_id, external_event_id),
UNIQUE (provider_id, nonce)
);
CREATE TABLE email_event (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
message_id INTEGER NOT NULL,
provider_id INTEGER,
event_type VARCHAR(500) NOT NULL,
bounce_type VARCHAR(500),
recipient VARCHAR(500),
occurred_date TIMESTAMP,
detail VARCHAR(500),
url TEXT,
event_key VARCHAR(500) NOT NULL UNIQUE
);
CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date);
CREATE INDEX email_event_message ON email_event (message_id);
CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date);
CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);
+11
View File
@@ -116,6 +116,12 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
@@ -178,6 +184,11 @@
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<excludes>
<exclude>com/kingsrook/qbits/email/demo/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
@@ -39,9 +39,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public class EmailQBitConfig implements QBitConfig
{
private com.kingsrook.qbits.email.template.MjmlCompilerInterface mjmlCompiler;
private java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> classificationOverrides = java.util.Map.of();
private java.util.function.Supplier<com.kingsrook.qbits.email.transport.EmailTransportRegistry> transportRegistryFactory = com.kingsrook.qbits.email.transport.EmailTransportRegistry::new;
private Set<String> allowedImageHosts = Set.of();
private String backendName;
private String schedulerName;
private String publicBaseUrl;
private String webhookBasePath = "/qqq/hooks/email";
private String defaultProviderName;
private String bridgeProviderName = "qbitEmail";
private Function<String, String> secretResolver = System::getenv;
@@ -85,6 +90,11 @@ public class EmailQBitConfig implements QBitConfig
@Override
public void validate(QInstance instance, List<String> errors)
{
if(webhookBasePath == null || !webhookBasePath.matches("/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+")
|| webhookBasePath.equals("/qqq/v1") || webhookBasePath.startsWith("/qqq/v1/"))
{
errors.add("webhookBasePath must be an absolute path outside /qqq/v1, without a trailing slash");
}
if(backendName == null || backendName.isBlank())
{
errors.add("backendName is required");
@@ -139,11 +149,11 @@ public class EmailQBitConfig implements QBitConfig
Set<String> names = new HashSet<>(allowedEnvNames);
names.addAll(switch(kind == null ? "" : kind)
{
case "SMTP", "GMAIL_SMTP" -> Set.of("EMAIL_SMTP_USERNAME", "EMAIL_SMTP_PASSWORD", "EMAIL_GMAIL_USERNAME", "EMAIL_GMAIL_APP_PASSWORD");
case "SMTP", "GMAIL_SMTP" -> Set.of("EMAIL_SMTP_USERNAME", "EMAIL_SMTP_PASSWORD", "EMAIL_GMAIL_USERNAME", "EMAIL_GMAIL_APP_PASSWORD", "GMAIL_USERNAME", "GMAIL_APP_PASSWORD");
case "SES" -> Set.of("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN");
case "POSTMARK" -> Set.of("EMAIL_POSTMARK_SERVER_TOKEN", "EMAIL_POSTMARK_WEBHOOK_PASSWORD");
case "SENDGRID" -> Set.of("EMAIL_SENDGRID_API_KEY", "EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY");
case "MAILGUN" -> Set.of("EMAIL_MAILGUN_API_KEY", "EMAIL_MAILGUN_SIGNING_KEY");
case "POSTMARK" -> Set.of("EMAIL_POSTMARK_SERVER_TOKEN", "EMAIL_POSTMARK_WEBHOOK_PASSWORD", "POSTMARK_SERVER_TOKEN", "POSTMARK_WEBHOOK_PASSWORD");
case "SENDGRID" -> Set.of("EMAIL_SENDGRID_API_KEY", "EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY", "SENDGRID_API_KEY", "SENDGRID_WEBHOOK_PUBLIC_KEY");
case "MAILGUN" -> Set.of("EMAIL_MAILGUN_API_KEY", "EMAIL_MAILGUN_SIGNING_KEY", "MAILGUN_API_KEY", "MAILGUN_WEBHOOK_SIGNING_KEY");
case "GMAIL_API" -> Set.of("EMAIL_GMAIL_CLIENT_ID", "EMAIL_GMAIL_CLIENT_SECRET", "EMAIL_GMAIL_REFRESH_TOKEN");
default -> Set.of();
});
@@ -197,6 +207,23 @@ public class EmailQBitConfig implements QBitConfig
return (publicBaseUrl);
}
/*******************************************************************************
** Public provider and unsubscribe route base.
*******************************************************************************/
public String getWebhookBasePath()
{
return (webhookBasePath);
}
/*******************************************************************************
** Configure the route base and queue-time unsubscribe URLs together.
*******************************************************************************/
public EmailQBitConfig withWebhookBasePath(String webhookBasePath)
{
this.webhookBasePath = webhookBasePath;
return (this);
}
/*******************************************************************************
** Fluent setter for publicBaseUrl.
@@ -510,4 +537,73 @@ public class EmailQBitConfig implements QBitConfig
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
return (this);
}
/*******************************************************************************
** Activation-time compiler, optional unless MJML templates are used.
*******************************************************************************/
public com.kingsrook.qbits.email.template.MjmlCompilerInterface getMjmlCompiler()
{
return (mjmlCompiler);
}
/*******************************************************************************
** Configure a compiler without introducing a runtime dependency.
*******************************************************************************/
public EmailQBitConfig withMjmlCompiler(com.kingsrook.qbits.email.template.MjmlCompilerInterface compiler)
{
mjmlCompiler = compiler;
return (this);
}
/*******************************************************************************
** Field classifications used until core supplies classification metadata.
*******************************************************************************/
public java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> getClassificationOverrides()
{
return (classificationOverrides);
}
/*******************************************************************************
** Configure explicit table.field classifications.
*******************************************************************************/
public EmailQBitConfig withClassificationOverrides(java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> overrides)
{
classificationOverrides = java.util.Map.copyOf(overrides);
return (this);
}
/*******************************************************************************
** Additional HTTPS image hosts permitted for approved email content.
*******************************************************************************/
public Set<String> getAllowedImageHosts()
{
return (allowedImageHosts);
}
/*******************************************************************************
** Configure exact host names, without wildcards or URL paths.
*******************************************************************************/
public EmailQBitConfig withAllowedImageHosts(Set<String> hosts)
{
allowedImageHosts = hosts.stream().map(host -> host.toLowerCase(java.util.Locale.ROOT)).collect(java.util.stream.Collectors.toUnmodifiableSet());
return (this);
}
/*******************************************************************************
** Fresh transports for every dispatcher, including process actions.
*******************************************************************************/
public com.kingsrook.qbits.email.transport.EmailTransportRegistry newTransportRegistry()
{
return (transportRegistryFactory.get());
}
/*******************************************************************************
** Host-scoped transport injection for embedded applications and local demos.
*******************************************************************************/
public EmailQBitConfig withTransportRegistryFactory(java.util.function.Supplier<com.kingsrook.qbits.email.transport.EmailTransportRegistry> factory)
{
transportRegistryFactory = java.util.Objects.requireNonNull(factory);
return (this);
}
}
@@ -143,7 +143,8 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
for(QTableMetaData table : EmailTables.tables(qBitConfig))
{
Class<?> customizer = EmailTables.PROVIDER.equals(table.getName()) ? EmailTableCustomizers.ProviderCustomizer.class
: EmailTables.SUPPRESSION.equals(table.getName()) ? EmailTableCustomizers.SuppressionCustomizer.class : null;
: EmailTables.SUPPRESSION.equals(table.getName()) ? EmailTableCustomizers.SuppressionCustomizer.class
: List.of(EmailTables.TEMPLATE, EmailTables.VERSION, EmailTables.BRAND).contains(table.getName()) ? com.kingsrook.qbits.email.template.EmailTemplateCustomizers.class : null;
if(customizer != null)
{
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(customizer))
@@ -154,6 +155,10 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.MessageLinksCustomizer.class));
}
if(EmailTables.VERSION.equals(table.getName()))
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.email.template.EmailVersionLabels.class));
}
if(EmailTables.PROVIDER.equals(table.getName()))
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.ProviderCustomizer.class));
@@ -190,6 +195,24 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
output.add(EmailApp.app(qBitConfig, tables));
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
registerWebhooks(qInstance);
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** Keep optional provided middleware linkage outside non-web applications.
*******************************************************************************/
private static void registerWebhooks(QInstance instance) throws QException
{
try
{
Class.forName("io.javalin.Javalin");
Class.forName("com.kingsrook.qqq.middleware.javalin.QJavalinMetaData");
}
catch(ClassNotFoundException e)
{
return;
}
com.kingsrook.qbits.email.webhook.EmailWebhookRouteProvider.register(instance);
}
}
@@ -29,7 +29,6 @@ import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRe
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
@@ -39,7 +38,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Email app with a placeholder overview and the five main tables in navigation.
** Email app with viewer-scoped dashboard widgets and the five main tables in navigation.
** Versions and attempts are child lists, and processes are record actions.
*******************************************************************************/
public final class EmailApp
@@ -59,14 +58,14 @@ public final class EmailApp
/*******************************************************************************
** Placeholder home widget.
** Standard home and record preview widgets.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
.withPermissionRules(EmailTables.permissionRules())));
widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class),
widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class)));
}
@@ -105,10 +104,18 @@ public final class EmailApp
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, "<p>Use Messages to follow queued email and Providers to manage delivery.</p>")));
return (new RenderWidgetOutput(EmailHome.overview()));
}
}
/*******************************************************************************
** Full-width widgets follow the Alerts home pattern.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, String type, Class<?> renderer)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type).withGridColumns(12).withIsCard(false)
.withShowReloadButton(true).withCodeReference(new QCodeReference(renderer)).withPermissionRules(EmailTables.permissionRules()));
}
}
@@ -0,0 +1,214 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Real viewer-scoped data. Counts preserve record security without sampling;
** inline bar charts and rows use the same theme-neutral styles as Webhooks.
*******************************************************************************/
public final class EmailHome
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHome()
{
}
/*******************************************************************************
** Counts and recent messages, without loading bodies or variable snapshots.
*******************************************************************************/
public static RawHTML overview() throws QException
{
Instant now = EmailRuntime.clock().instant();
List<Integer> totals = new ArrayList<>();
String[] fields = {"sentDate", "deliveredDate", "deadLetterDate", "bouncedDate", "complainedDate"};
for(int i = 0; i < fields.length; i++)
{
String eventType = switch(i)
{
case 1 -> "DELIVERED";
case 3 -> "BOUNCED";
case 4 -> "COMPLAINED";
default -> null;
};
String field = eventType == null ? fields[i] : "occurredDate";
QQueryFilter filter = since(field, now.minus(i < 3 ? 1 : 7, ChronoUnit.DAYS))
.withCriteria(field, QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
if(eventType != null)
{
filter.withCriteria("eventType", QCriteriaOperator.EQUALS, eventType);
}
totals.add(count(eventType == null ? EmailTables.MESSAGE : EmailTables.EVENT, filter));
}
totals.add(count(EmailTables.SUPPRESSION, new QQueryFilter()));
List<QRecord> messages = query(EmailTables.MESSAGE, new QQueryFilter().withOrderBy(new QFilterOrderBy("createDate", false))
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(20), List.of("id", "createDate", "toAddresses", "subject", "phiApproved", "status"));
boolean admin = EmailPermissionChecker.has(EmailPermissions.ADMIN);
List<QRecord> providers = admin ? query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)),
List.of("id", "label", "name", "enabled", "healthStatus", "lastSuccessDate", "lastFailureMessage", "maxPerMinute")) : List.of();
for(QRecord provider : providers)
{
Integer limit = provider.getValueInteger("maxPerMinute");
if(limit != null)
{
int used = count(EmailTables.ATTEMPT, since("startDate", now.minusSeconds(60))
.withCriteria("startDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withCriteria("providerId", QCriteriaOperator.EQUALS, provider.getValue("id")));
provider.setValue("headroom", Math.max(0, limit - used));
}
}
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions())));
}
/*******************************************************************************
** Twenty-four UTC hour buckets, counting successful and failed attempts.
*******************************************************************************/
public static ChartData hourly() throws QException
{
Instant start = EmailRuntime.clock().instant().truncatedTo(ChronoUnit.HOURS).minus(23, ChronoUnit.HOURS);
List<String> labels = new ArrayList<>();
List<Number> sent = new ArrayList<>();
List<Number> failed = new ArrayList<>();
for(int i = 0; i < 24; i++)
{
Instant hour = start.plus(i, ChronoUnit.HOURS);
labels.add(DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH).withZone(ZoneOffset.UTC).format(hour));
QQueryFilter filter = since("endDate", hour).withCriteria("endDate", QCriteriaOperator.LESS_THAN, hour.plus(1, ChronoUnit.HOURS));
sent.add(count(EmailTables.ATTEMPT, filter.clone().withCriteria("outcome", QCriteriaOperator.EQUALS, "SUCCEEDED")));
failed.add(count(EmailTables.ATTEMPT, filter.clone().withCriteria("outcome", QCriteriaOperator.IN, List.of("TRANSIENT_FAILURE", "PROVIDER_FAILURE", "PERMANENT_FAILURE", "LEASE_EXPIRED"))));
}
return (chart(labels, List.of(dataset("Sent", sent, "#16a34a"), dataset("Failed", failed, "#dc2626")), "No completed attempts in the last 24 hours. Counts use UTC hours."));
}
/*******************************************************************************
** Suppression rows created per UTC day and reason, for thirty days.
*******************************************************************************/
public static ChartData suppressions() throws QException
{
Instant start = EmailRuntime.clock().instant().truncatedTo(ChronoUnit.DAYS).minus(29, ChronoUnit.DAYS);
List<String> labels = new ArrayList<>();
List<ChartData.Data.Dataset> datasets = new ArrayList<>();
List<String> reasons = List.of("HARD_BOUNCE", "COMPLAINT", "UNSUBSCRIBE", "MANUAL");
List<String> colors = List.of("#dc2626", "#d97706", "#2563eb", "#6b7280");
for(int r = 0; r < reasons.size(); r++)
{
List<Number> counts = new ArrayList<>();
for(int i = 0; i < 30; i++)
{
Instant day = start.plus(i, ChronoUnit.DAYS);
if(r == 0)
{
labels.add(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH).withZone(ZoneOffset.UTC).format(day));
}
counts.add(count(EmailTables.SUPPRESSION, since("createdDate", day).withCriteria("createdDate", QCriteriaOperator.LESS_THAN, day.plus(1, ChronoUnit.DAYS))
.withCriteria("reason", QCriteriaOperator.EQUALS, reasons.get(r))));
}
datasets.add(dataset(com.kingsrook.qbits.email.metadata.EmailEnums.SuppressionReason.valueOf(reasons.get(r)).getPossibleValueLabel(), counts, colors.get(r)));
}
return (chart(labels, datasets, "No new suppressions in the last 30 days. Counts use UTC dates."));
}
/*******************************************************************************
** Standard chart with an honest empty-state sublabel.
*******************************************************************************/
private static ChartData chart(List<String> labels, List<ChartData.Data.Dataset> datasets, String empty)
{
ChartData chart = new ChartData().withChartData(new ChartData.Data().withLabels(labels).withDatasets(datasets));
boolean any = datasets.stream().anyMatch(d -> d.getData().stream().anyMatch(n -> n.doubleValue() > 0));
chart.setSublabel(any ? "Real data visible to your session; UTC buckets." : empty);
return (chart);
}
/*******************************************************************************
** A standard chart series.
*******************************************************************************/
private static ChartData.Data.Dataset dataset(String label, List<Number> values, String color)
{
return (new ChartData.Data.Dataset().withLabel(label).withData(values).withColor(color));
}
/*******************************************************************************
** Mask display names and all but the first local-part character.
*******************************************************************************/
public static String mask(String recipients)
{
try
{
String address = jakarta.mail.internet.InternetAddress.parse(recipients)[0].getAddress();
int at = address.lastIndexOf('@');
return (at > 0 ? address.charAt(0) + "***" + address.substring(at) : "[masked]");
}
catch(Exception e)
{
return ("[masked]");
}
}
/*******************************************************************************
** Date lower bound, always used with viewer-scoped actions.
*******************************************************************************/
private static QQueryFilter since(String field, Instant since)
{
return (new QQueryFilter().withCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
}
/*******************************************************************************
** Exact count as the viewer, without authorized scopes.
*******************************************************************************/
private static int count(String table, QQueryFilter filter) throws QException
{
return (new CountAction().execute(new CountInput(table).withFilter(filter)).getCount());
}
/*******************************************************************************
** Select only home fields; row and field security remain active.
*******************************************************************************/
private static List<QRecord> query(String table, QQueryFilter filter, List<String> fields) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(new java.util.HashSet<>(fields)).withShouldFetchHeavyFields(true)).getRecords());
}
}
@@ -0,0 +1,243 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.metadata.EmailEnums;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Inline styles survive the UI sanitizer. Grids collapse into labelled cards;
** bars shrink with their container and all ordinary text inherits the theme.
*******************************************************************************/
public final class EmailHomeView
{
private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box;";
private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;";
private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);";
private static final String FAIL = "#d64545";
private static final String OK = "#2e9e5b";
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHomeView()
{
}
/*******************************************************************************
** One ordered home with real, viewer-scoped data supplied by EmailHome.
*******************************************************************************/
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions)
{
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
html.append("<div style=\"" + WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr));gap:12px\">");
List<String> labels = List.of("Sent (24 h)", "Delivered (24 h)", "Dead letters (24 h)", "Bounced (7 d)", "Complaints (7 d)", "Suppressed addresses");
for(int i = 0; i < labels.size(); i++)
{
html.append("<div style=\"").append(CARD).append("padding:12px\"><div style=\"font-size:1.6rem;font-weight:600;line-height:1.2;")
.append(i >= 2 && i <= 4 && totals.get(i) > 0 ? "color:" + FAIL : "color:inherit").append("\">")
.append(String.format(Locale.ENGLISH, "%,d", totals.get(i))).append("</div><div style=\"font-size:.85rem;opacity:.75\">")
.append(labels.get(i)).append("</div></div>");
}
html.append("</div>");
bars(html, "Sends per hour, last 24 hours", hourly);
providers(html, providers, admin);
messages(html, messages);
bars(html, "Suppressions added, last 30 days", suppressions);
return (html.append("</div>").toString());
}
/*******************************************************************************
** A bounded stacked bar chart, including exact values in each bar's title.
*******************************************************************************/
private static void bars(StringBuilder html, String title, ChartData chart)
{
section(html, title);
var data = chart.getChartData();
int max = 0;
for(int i = 0; i < data.getLabels().size(); i++)
{
int total = 0;
for(var dataset : data.getDatasets())
{
total += dataset.getData().get(i).intValue();
}
max = Math.max(max, total);
}
if(max == 0)
{
html.append("<p style=\"margin:0;opacity:.75\">").append(esc(chart.getSublabel())).append("</p></section>");
return;
}
html.append("<div style=\"font-size:.75rem;opacity:.75\">Peak ").append(max).append("</div>")
.append("<div role=\"img\" aria-label=\"").append(esc(title)).append("; stacked counts, UTC\" style=\"")
.append(WRAP).append("display:flex;align-items:flex-end;gap:2px;height:140px;width:100%\">");
for(int i = 0; i < data.getLabels().size(); i++)
{
StringBuilder description = new StringBuilder(data.getLabels().get(i));
for(var dataset : data.getDatasets())
{
description.append("; ").append(dataset.getLabel()).append(": ").append(dataset.getData().get(i));
}
html.append("<div title=\"").append(esc(description.toString())).append("\" style=\"flex:1 1 0;min-width:0;height:100%;display:flex;flex-direction:column-reverse\">");
for(var dataset : data.getDatasets())
{
html.append("<div style=\"background:").append(dataset.getColor()).append(";height:")
.append(String.format(Locale.ROOT, "%.3f", 100.0 * dataset.getData().get(i).doubleValue() / max)).append("%\"></div>");
}
html.append("</div>");
}
html.append("</div><div style=\"display:flex;justify-content:space-between;gap:8px;min-width:0;font-size:.75rem;opacity:.75;margin-top:4px\">");
for(int i : new int[] {0, data.getLabels().size() / 2, data.getLabels().size() - 1})
{
html.append("<span>").append(esc(data.getLabels().get(i))).append("</span>");
}
html.append("</div><div style=\"display:flex;flex-wrap:wrap;gap:8px 16px;min-width:0;font-size:.8rem;margin-top:8px\">");
for(var dataset : data.getDatasets())
{
html.append("<span><span style=\"display:inline-block;width:.7rem;height:.7rem;margin-right:5px;background:")
.append(dataset.getColor()).append("\"></span>").append(esc(dataset.getLabel())).append("</span>");
}
html.append("<span style=\"opacity:.75\">UTC</span></div></section>");
}
/*******************************************************************************
** Provider health is admin-only; disabled providers never appear healthy.
*******************************************************************************/
private static void providers(StringBuilder html, List<QRecord> providers, boolean admin)
{
section(html, "Provider health");
if(!admin || providers.isEmpty())
{
html.append("<p style=\"margin:0;opacity:.75\">").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("</p>");
}
for(QRecord provider : providers)
{
String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled"
: "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown";
String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080";
html.append("<div style=\"").append(ROW).append("\">")
.append(cell("Provider", "<strong>" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "</strong><br><span style=\"color:" + color + "\">" + status + "</span>"))
.append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate")))))
.append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None"))))
.append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("</div>");
}
html.append("</section>");
}
/*******************************************************************************
** The whole row is a link. Labelled columns stack as cards on narrow screens.
*******************************************************************************/
private static void messages(StringBuilder html, List<QRecord> messages)
{
section(html, "Recent messages");
if(messages.isEmpty())
{
html.append("<p style=\"margin:0;opacity:.75\">No messages yet.</p>");
}
for(QRecord message : messages)
{
String subject = Boolean.TRUE.equals(message.getValueBoolean("phiApproved")) || "[encrypted]".equals(message.getValueString("subject"))
? "Secure message" : value(message, "subject", "Message " + message.getValueInteger("id"));
String status = message.getValueString("status");
html.append("<a href=\"/app/").append(EmailTables.MESSAGE).append('/').append(message.getValueInteger("id"))
.append("\" style=\"").append(ROW).append("color:inherit;text-decoration:none\">")
.append(cell("When", esc(when(message.getValueInstant("createDate")))))
.append(cell("To", esc(EmailHome.mask(message.getValueString("toAddresses")))))
.append(cell("Subject", esc(subject)))
.append(cell("Status", esc(status == null ? "Unknown" : EmailEnums.MessageStatus.valueOf(status).getPossibleValueLabel())))
.append("</a>");
}
html.append("</section>");
}
/*******************************************************************************
** One bordered section, using the surrounding theme's text colour.
*******************************************************************************/
private static void section(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 12px;font-size:1rem;font-weight:600\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
** A labelled, shrinkable cell that remains understandable when stacked.
*******************************************************************************/
private static String cell(String label, String content)
{
return ("<div style=\"" + WRAP + "\"><span style=\"display:block;font-size:.75rem;opacity:.65\">" + esc(label) + "</span> " + content + "</div>");
}
/*******************************************************************************
** Compact UTC dates, with an honest missing value.
*******************************************************************************/
private static String when(Instant instant)
{
return (instant == null ? "None" : WHEN.format(instant));
}
/*******************************************************************************
** Blank fields use a human-readable fallback.
*******************************************************************************/
private static String value(QRecord row, String field, String fallback)
{
String value = row.getValueString(field);
return (value == null || value.isBlank() ? fallback : value);
}
/*******************************************************************************
** Escape every record-sourced value before inserting HTML.
*******************************************************************************/
private static String esc(String value)
{
return (EmailTemplateEngine.escape(value));
}
}
@@ -59,7 +59,8 @@ public class EmailOutboxMessagingProvider implements MessagingProviderInterface
{
throw (new QException("Email attachments are not supported in part 1"));
}
EmailRequest request = new EmailRequest().withSubject(input.getSubject());
EmailRequest request = input instanceof EmailTemplatedMessageInput templated && templated.getEmailRequest() != null
? templated.getEmailRequest() : new EmailRequest().withSubject(input.getSubject());
parties(request, input.getTo(), EmailPartyRole.TO);
parties(request, input.getFrom(), EmailPartyRole.FROM);
if(input.getContentList() != null)
@@ -0,0 +1,52 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.bridge;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
/*******************************************************************************
** Optional typed template payload for core SendMessageAction bridge callers.
** Plain SendMessageInput remains an ad-hoc message for backwards compatibility.
*******************************************************************************/
public class EmailTemplatedMessageInput extends SendMessageInput
{
private EmailRequest emailRequest;
/*******************************************************************************
** Set schema variables and record references for the normal validated API.
*******************************************************************************/
public EmailTemplatedMessageInput withEmailRequest(EmailRequest request)
{
emailRequest = request;
return (this);
}
/*******************************************************************************
** The caller's template request; party fields can still come from core input.
*******************************************************************************/
public EmailRequest getEmailRequest()
{
return (emailRequest);
}
}
@@ -35,6 +35,7 @@ import com.kingsrook.qqq.backend.core.alerts.QAlerts;
*******************************************************************************/
public final class EmailAlerts
{
public static final String COMPLAINT = "email.complaint";
public static final String UNHEALTHY = "email.provider.unhealthy";
public static final String DEAD_LETTER = "email.message.dead-letter";
@@ -50,7 +51,9 @@ public final class EmailAlerts
*******************************************************************************/
public static List<QAlertTypeMetaData> types()
{
return (List.of(new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
return (List.of(new QAlertTypeMetaData().withName(COMPLAINT).withLabel("Email complaints").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(EmailTables.EVENT).withVisibilityPermission("email.view").withSourceName("email"),
new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
.withSubjectTableName(EmailTables.PROVIDER).withVisibilityPermission("email.admin").withSourceName("email"),
new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Email dead letters").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(EmailTables.MESSAGE).withVisibilityPermission("email.view").withSourceName("email")));
@@ -62,7 +65,11 @@ public final class EmailAlerts
public static void raise(String type, Integer providerId)
{
String key = "provider:" + providerId + (DEAD_LETTER.equals(type) ? ":" + EmailRuntime.clock().instant().getEpochSecond() / 3600 : "");
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
if(COMPLAINT.equals(type))
{
key = "complaints:" + EmailRuntime.clock().instant().getEpochSecond() / 86400;
}
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(COMPLAINT.equals(type) ? "More than three email complaints in 24 hours" : UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
.withSummary("Review the Email tables for status and retry actions."));
}
@@ -67,7 +67,7 @@ public class EmailDispatcher
*******************************************************************************/
public EmailDispatcher()
{
this(new EmailTransportRegistry());
this(EmailRuntime.config() == null ? new EmailTransportRegistry() : EmailRuntime.config().newTransportRegistry());
}
/*******************************************************************************
@@ -201,6 +201,10 @@ public class EmailDispatcher
EmailTransportException error = null;
try
{
if(Boolean.TRUE.equals(claim.message().getValueBoolean("phiApproved")))
{
com.kingsrook.qbits.email.template.EmailTemplateRenderer.checkProvider(claim.provider());
}
result = transports.get(claim.provider().getValueString("kind")).send(envelope(claim.message()), new EmailProviderSettings(claim.provider(), EmailRuntime.config()));
}
catch(EmailTransportException e)
@@ -346,9 +350,9 @@ public class EmailDispatcher
{
safeError = "Credentials missing or refused";
}
if(error != null && "Provider transport not available until phase (b)".equals(error.getMessage()))
if(error != null && "Gmail API sending is specified but not built; use GMAIL_SMTP".equals(error.getMessage()))
{
safeError = "Provider transport not available until phase (b)";
safeError = "Gmail API sending is specified but not built; use GMAIL_SMTP";
}
EmailStore.update(EmailTables.ATTEMPT, new QRecord().withValue("id", claim.attempt().getValue("id"))
.withValue("outcome", success ? "SUCCEEDED" : providerLevel ? "PROVIDER_FAILURE" : permanent ? "PERMANENT_FAILURE" : "TRANSIENT_FAILURE")
@@ -449,8 +453,28 @@ public class EmailDispatcher
{
return (new EmailEnvelope().withMessageId(message.getValueString("id")).withFrom(message.getValueString("fromAddress"))
.withTo(split(message.getValueString("toAddresses"))).withCc(split(message.getValueString("ccAddresses"))).withBcc(split(message.getValueString("bccAddresses")))
.withReplyTo(message.getValueString("replyTo")).withSubject(message.getValueString("subject"))
.withHtmlBody(message.getValueString("htmlBody")).withTextBody(message.getValueString("textBody")));
.withReplyTo(message.getValueString("replyTo")).withSubject(content(message, "subject"))
.withHtmlBody(content(message, "htmlBody"))
.withTextBody(content(message, "textBody"))
.withHeaders(message.getValueString("unsubscribeUrl") == null ? java.util.Map.of() : java.util.Map.of("List-Unsubscribe", "<" + message.getValueString("unsubscribeUrl") + ">", "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")));
}
/*******************************************************************************
** Only policy-marked messages may invoke body decryption. A caller-supplied
** enc:v1 string in ad-hoc content is text, never a decryption capability.
*******************************************************************************/
private static String content(QRecord message, String field)
{
String value = message.getValueString(field);
if(!Boolean.TRUE.equals(message.getValueBoolean("phiApproved")))
{
return (value);
}
if(value != null && !value.startsWith("enc:v1:"))
{
throw (new IllegalArgumentException("Protected email content must be encrypted"));
}
return (com.kingsrook.qbits.email.phi.EmailBodyEncryption.decrypt(value, field, message.getValueString("id")));
}
/*******************************************************************************
@@ -130,7 +130,7 @@ public final class EmailEnums
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
return (this == RETRY_WAIT ? "Waiting to retry" : name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
@@ -312,4 +312,88 @@ public final class EmailEnums
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** InboxStatus values.
*******************************************************************************/
public enum InboxStatus implements PossibleValueEnum<String>
{
PENDING, PROCESSED, FAILED, IGNORED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** EventType values.
*******************************************************************************/
public enum EventType implements PossibleValueEnum<String>
{
DELIVERED, DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** BounceType values.
*******************************************************************************/
public enum BounceType implements PossibleValueEnum<String>
{
HARD, SOFT, UNDETERMINED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
}
@@ -77,6 +77,37 @@ public final class EmailTableCustomizers
{
List<String> errors = new ArrayList<>();
String kind = row.getValueString("kind");
if("GMAIL_API".equals(kind))
{
errors.add("Gmail API sending is specified but not built; use GMAIL_SMTP");
}
if(List.of("POSTMARK", "SENDGRID", "MAILGUN", "GMAIL_SMTP").contains(kind == null ? "" : kind)
&& (row.getValueString("secretEnvName") == null || row.getValueString("secretEnvName").isBlank()))
{
errors.add("Provider needs a secret environment name");
}
if("GMAIL_SMTP".equals(kind))
{
if(row.getValueString("usernameEnvName") == null || row.getValueString("usernameEnvName").isBlank())
{
errors.add("Gmail SMTP needs an account address environment name");
}
if(row.getValueString("security") != null && !"STARTTLS".equals(row.getValueString("security")))
{
errors.add("Gmail SMTP requires STARTTLS");
}
}
if("MAILGUN".equals(kind))
{
if(!EmailProviderSettings.validSendingDomain(row.getValueString("sendingDomain")))
{
errors.add("Mailgun needs a valid sending domain");
}
if(!List.of("US", "EU").contains(row.getValueString("region") == null ? "" : row.getValueString("region")))
{
errors.add("Mailgun region must be US or EU");
}
}
if(("SMTP".equals(kind) || "GMAIL_SMTP".equals(kind))
&& !new EmailProviderSettings(row, config).allowsSmtpAuth())
{
@@ -208,6 +239,11 @@ public final class EmailTableCustomizers
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
for(QRecord row : records)
{
String label = row.getValueString("label");
row.setRecordLabel(label == null || label.isBlank() ? row.getValueString("name") : label);
}
if(!SystemWrites.active())
{
for(QRecord row : records)
@@ -308,6 +344,27 @@ public final class EmailTableCustomizers
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
if(!SystemWrites.active())
{
for(QRecord row : records)
{
for(String field : List.of("subject", "htmlBody", "textBody"))
{
String value = row.getValueString(field);
if(value != null && value.startsWith("enc:v1:"))
{
row.setValue(field, "[encrypted]");
row.setDisplayValue(field, "[encrypted]");
}
}
}
}
for(QRecord row : records)
{
String subject = row.getValueString("subject");
boolean secure = Boolean.TRUE.equals(row.getValueBoolean("phiApproved")) || "[encrypted]".equals(subject) || (subject != null && subject.startsWith("enc:v1:"));
row.setRecordLabel(secure ? "Secure message" : subject == null || subject.isBlank() ? "Message " + row.getValue("id") : subject);
}
if(input.getShouldGenerateDisplayValues())
{
for(QRecord row : records)
@@ -59,9 +59,11 @@ public final class EmailTables
public static final String VERSION = "email_template_version";
public static final String BRAND = "email_brand";
public static final String SUPPRESSION = "email_suppression";
public static final List<String> ALL = List.of(PROVIDER, MESSAGE, ATTEMPT, TEMPLATE, VERSION, BRAND, SUPPRESSION);
public static final String INBOX = "email_webhook_inbox";
public static final String EVENT = "email_event";
public static final List<String> ALL = List.of(PROVIDER, MESSAGE, ATTEMPT, TEMPLATE, VERSION, BRAND, SUPPRESSION, INBOX, EVENT);
public static final List<String> MAIN = List.of(MESSAGE, TEMPLATE, BRAND, SUPPRESSION, PROVIDER);
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT);
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT, INBOX, EVENT);
/*******************************************************************************
** Utility class.
@@ -95,12 +97,23 @@ public final class EmailTables
result.add(QPossibleValueSource.newForEnum("email_suppression_scope", EmailEnums.SuppressionScope.values()));
result.add(QPossibleValueSource.newForEnum("email_suppression_reason", EmailEnums.SuppressionReason.values()));
result.add(QPossibleValueSource.newForEnum("email_category", EmailCategory.values()));
result.add(QPossibleValueSource.newForEnum("email_inbox_status", EmailEnums.InboxStatus.values()));
result.add(QPossibleValueSource.newForEnum("email_event_type", EmailEnums.EventType.values()));
result.add(QPossibleValueSource.newForEnum("email_bounce_type", EmailEnums.BounceType.values()));
ALL.forEach(name -> result.add(QPossibleValueSource.newForTable(name)));
for(QPossibleValueSource source : result)
{
if(List.of(PROVIDER, MESSAGE, VERSION).contains(source.getName()))
{
source.withSearchFields(PROVIDER.equals(source.getName()) ? List.of("label", "name") : MESSAGE.equals(source.getName()) ? List.of("subject") : List.of("versionNumber", "locale"))
.withOrderByField("id");
}
}
return (result);
}
/*******************************************************************************
** The seven phase (a) tables.
** Outbound delivery and tracking tables.
*******************************************************************************/
public static List<QTableMetaData> tables(EmailQBitConfig config)
{
@@ -112,6 +125,8 @@ public final class EmailTables
tables.add(templateversion(config));
tables.add(brand(config));
tables.add(suppression(config));
tables.add(inbox(config));
tables.add(event(config));
for(QTableMetaData table : tables)
{
for(QFieldMetaData field : config.getSecurityFields())
@@ -189,13 +204,14 @@ public final class EmailTables
.withField(field("lastFailureMessage", QFieldType.STRING).withMaxLength(500))
.withField(field("dispatchLock", QFieldType.INTEGER).withDefaultValue(0).withIsHidden(true).withIsEditable(false))
.withField(field("defaultSlot", QFieldType.INTEGER).withIsHidden(true).withIsEditable(false));
table.withRecordLabelFormatAndFields("%s", "name");
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.withUniqueKey(new UniqueKey("name"));
table.withUniqueKey(new UniqueKey("defaultSlot"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "kind", "enabled", "isDefault"));
table.withSection(SectionFactory.defaultT2("fromAddress", "fromName", "replyTo", "sendingDomain", "host", "port", "security", "region", "usernameEnvName", "secretEnvName", "webhookSecretEnvName", "snsTopicArn", "configurationSet", "messageStream", "maxPerMinute", "maxPerDay", "trackOpens", "trackClicks", "phiAllowed", "healthStatus", "lastSuccessDate", "lastFailureDate", "lastFailureMessage"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Messages").withWidgetName(PROVIDER + "_children"));
table.withSection(SectionFactory.customT2("inbox", new QIcon("list")).withLabel("Webhook Inbox").withWidgetName(INBOX + "_children"));
return (table);
}
@@ -214,13 +230,15 @@ public final class EmailTables
.withField(field("replyTo", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE))
.withField(field("templateVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION))
.withField(field("templateVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION).withPossibleValueSourceFilter(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.templateId}")))
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500))
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true))
.withField(field("htmlBody", QFieldType.TEXT).withIsSensitive(true))
.withField(field("textBody", QFieldType.TEXT).withIsSensitive(true))
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
.withField(field("unsubscribeUrl", QFieldType.TEXT))
.withField(field("bodySha256", QFieldType.STRING).withMaxLength(500))
.withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500))
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
@@ -232,6 +250,8 @@ public final class EmailTables
.withField(field("finishedDate", QFieldType.DATE_TIME))
.withField(field("sentDate", QFieldType.DATE_TIME))
.withField(field("deliveredDate", QFieldType.DATE_TIME))
.withField(field("bouncedDate", QFieldType.DATE_TIME))
.withField(field("complainedDate", QFieldType.DATE_TIME))
.withField(field("deadLetterDate", QFieldType.DATE_TIME))
.withField(field("linkedTableName", QFieldType.STRING).withMaxLength(500))
.withField(field("linkedRecordId", QFieldType.STRING).withMaxLength(500)
@@ -240,11 +260,14 @@ public final class EmailTables
.withField(field("senderProcess", QFieldType.STRING).withMaxLength(500))
.withField(field("suppressedAddresses", QFieldType.TEXT).withIsSensitive(true))
.withField(field("sendAfter", QFieldType.DATE_TIME));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.getField("subject").setIsHeavy(false);
table.withUniqueKey(new UniqueKey("idempotencyKey"));
table.withSection(SectionFactory.defaultT1("id", "status", "toAddresses", "ccAddresses", "bccAddresses", "fromAddress"));
table.withSection(SectionFactory.defaultT2("replyTo", "subject", "templateId", "templateVersionId", "brandId", "category", "locale", "variablesSnapshot", "htmlBody", "textBody", "bodySha256", "idempotencyKey", "providerId", "providerMessageId", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastError", "finishedDate", "sentDate", "deliveredDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderQbit", "senderProcess", "suppressedAddresses", "sendAfter"));
table.withSection(SectionFactory.defaultT2("replyTo", "subject", "templateId", "templateVersionId", "brandId", "category", "locale", "variablesSnapshot", "htmlBody", "textBody", "phiApproved", "unsubscribeUrl", "bodySha256", "idempotencyKey", "providerId", "providerMessageId", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastError", "finishedDate", "sentDate", "deliveredDate", "bouncedDate", "complainedDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderQbit", "senderProcess", "suppressedAddresses", "sendAfter"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Attempts").withWidgetName(MESSAGE + "_children"));
table.withSection(SectionFactory.customT2("events", new QIcon("list")).withLabel("Events").withWidgetName(EVENT + "_children"));
return (table);
}
@@ -284,17 +307,19 @@ public final class EmailTables
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_template_kind").withIsRequired(true).withMaxLength(500))
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
.withField(field("activeVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION))
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("phiApprovedBy", QFieldType.STRING).withMaxLength(500))
.withField(field("phiApprovedDate", QFieldType.DATE_TIME))
.withField(field("activeVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION).withIsEditable(false).withPossibleValueSourceFilter(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.id}")))
.withField(field("approvedVersionId", QFieldType.INTEGER).withIsEditable(false).withPossibleValueSourceName(VERSION).withPossibleValueSourceFilter(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.id}")))
.withField(field("versionLock", QFieldType.INTEGER).withIsHidden(true).withIsEditable(false))
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
.withField(field("phiApprovedBy", QFieldType.STRING).withMaxLength(500).withIsEditable(false))
.withField(field("phiApprovedDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("editPermissionName", QFieldType.STRING).withMaxLength(500))
.withField(field("ownerQbit", QFieldType.STRING).withMaxLength(500))
.withField(field("description", QFieldType.TEXT));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "category", "kind", "brandId"));
table.withSection(SectionFactory.defaultT2("activeVersionId", "phiApproved", "phiApprovedBy", "phiApprovedDate", "editPermissionName", "ownerQbit", "description"));
table.withSection(SectionFactory.defaultT2("activeVersionId", "approvedVersionId", "phiApproved", "phiApprovedBy", "phiApprovedDate", "editPermissionName", "ownerQbit", "description"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Versions").withWidgetName(TEMPLATE + "_children"));
return (table);
@@ -307,20 +332,22 @@ public final class EmailTables
private static QTableMetaData templateversion(EmailQBitConfig config)
{
QTableMetaData table = base(config, VERSION, "Template Version")
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE))
.withField(field("versionNumber", QFieldType.INTEGER))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_version_status").withMaxLength(500))
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500))
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE).withIsRequired(true))
.withField(field("versionNumber", QFieldType.INTEGER).withIsRequired(true))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_version_status").withDefaultValue("DRAFT").withIsEditable(false).withMaxLength(500))
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500).withIsRequired(true))
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
.withField(field("preheader", QFieldType.TEXT))
.withField(field("bodySource", QFieldType.TEXT))
.withField(field("textSource", QFieldType.TEXT))
.withField(field("compiledHtml", QFieldType.TEXT))
.withField(field("compiledHtml", QFieldType.TEXT).withIsEditable(false))
.withField(field("variablesSchema", QFieldType.TEXT))
.withField(field("sampleVariables", QFieldType.TEXT))
.withField(field("createdBy", QFieldType.STRING).withMaxLength(500))
.withField(field("activatedDate", QFieldType.DATE_TIME));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.withUniqueKey(new UniqueKey("templateId", "versionNumber", "locale"));
table.withSection(SectionFactory.customT2("preview", new QIcon("preview")).withLabel("Preview").withWidgetName("emailTemplatePreview"));
table.withSection(SectionFactory.defaultT1("id", "templateId", "versionNumber", "status", "locale", "subject"));
table.withSection(SectionFactory.defaultT2("preheader", "bodySource", "textSource", "compiledHtml", "variablesSchema", "sampleVariables", "createdBy", "activatedDate"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
@@ -343,6 +370,8 @@ public final class EmailTables
.withField(field("textColor", QFieldType.STRING).withMaxLength(500))
.withField(field("backgroundColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkBackgroundColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkPrimaryColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkButtonTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("buttonTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("footerMarkdown", QFieldType.TEXT))
@@ -354,7 +383,7 @@ public final class EmailTables
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "logoUrl", "logoDarkUrl", "logoAlt"));
table.withSection(SectionFactory.defaultT2("primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "footerMarkdown", "postalAddress", "socialLinks", "siteUrl", "isDefault", "appName"));
table.withSection(SectionFactory.defaultT2("primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "darkPrimaryColor", "darkButtonTextColor", "buttonTextColor", "footerMarkdown", "postalAddress", "socialLinks", "siteUrl", "isDefault", "appName"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
@@ -370,7 +399,7 @@ public final class EmailTables
.withField(field("scope", QFieldType.STRING).withPossibleValueSourceName("email_suppression_scope").withIsRequired(true).withMaxLength(500))
.withField(field("reason", QFieldType.STRING).withPossibleValueSourceName("email_suppression_reason").withIsRequired(true).withMaxLength(500))
.withField(field("sourceMessageId", QFieldType.INTEGER))
.withField(field("sourceEventId", QFieldType.INTEGER))
.withField(field("sourceEventId", QFieldType.INTEGER).withPossibleValueSourceName(EVENT))
.withField(field("note", QFieldType.TEXT))
.withField(field("createdDate", QFieldType.DATE_TIME))
.withField(field("expiresDate", QFieldType.DATE_TIME));
@@ -381,12 +410,59 @@ public final class EmailTables
return (table);
}
/*******************************************************************************
** System-written webhook inbox records.
*******************************************************************************/
private static QTableMetaData inbox(EmailQBitConfig config)
{
QTableMetaData table = base(config, INBOX, "Webhook Inbox")
.withField(field("providerId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(PROVIDER))
.withField(field("kind", QFieldType.STRING).withMaxLength(500))
.withField(field("externalEventId", QFieldType.STRING).withMaxLength(500).withIsRequired(true))
.withField(field("nonce", QFieldType.STRING).withMaxLength(500))
.withField(field("status", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withPossibleValueSourceName("email_inbox_status"))
.withField(field("attempts", QFieldType.INTEGER))
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
.withField(field("payloadJson", QFieldType.TEXT).withIsSensitive(true))
.withField(field("payloadSha256", QFieldType.STRING).withMaxLength(500))
.withField(field("receivedDate", QFieldType.DATE_TIME))
.withField(field("processedDate", QFieldType.DATE_TIME));
table.withUniqueKey(new UniqueKey("providerId", "externalEventId"));
table.withUniqueKey(new UniqueKey("providerId", "nonce"));
table.withSection(SectionFactory.defaultT1("id", "providerId", "kind", "externalEventId", "nonce", "status", "attempts", "nextAttemptDate", "lastError", "payloadJson", "payloadSha256", "receivedDate", "processedDate"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** System-written event records.
*******************************************************************************/
private static QTableMetaData event(EmailQBitConfig config)
{
QTableMetaData table = base(config, EVENT, "Event")
.withField(field("messageId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(MESSAGE))
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
.withField(field("eventType", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withPossibleValueSourceName("email_event_type"))
.withField(field("bounceType", QFieldType.STRING).withMaxLength(500).withPossibleValueSourceName("email_bounce_type"))
.withField(field("recipient", QFieldType.STRING).withMaxLength(500).withIsSensitive(true))
.withField(field("occurredDate", QFieldType.DATE_TIME))
.withField(field("detail", QFieldType.STRING).withMaxLength(500))
.withField(field("url", QFieldType.TEXT).withIsSensitive(true))
.withField(field("eventKey", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withIsHidden(true));
table.withUniqueKey(new UniqueKey("eventKey"));
table.withSection(SectionFactory.defaultT1("id", "messageId", "providerId", "eventType", "bounceType", "recipient", "occurredDate", "detail", "url"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Relations used by child lists.
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
return (List.of(join(PROVIDER, MESSAGE, "providerId"), join(MESSAGE, ATTEMPT, "messageId"), join(TEMPLATE, VERSION, "templateId")));
return (List.of(join(PROVIDER, MESSAGE, "providerId"), join(MESSAGE, ATTEMPT, "messageId"), join(TEMPLATE, VERSION, "templateId"), join(PROVIDER, INBOX, "providerId"), join(MESSAGE, EVENT, "messageId")));
}
/*******************************************************************************
@@ -406,8 +482,8 @@ public final class EmailTables
List<QWidgetMetaData> widgets = new ArrayList<>();
for(QJoinMetaData join : joins())
{
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getLeftTable() + "_children")
.withLabel(join.getRightTable().equals(ATTEMPT) ? "Attempts" : join.getRightTable().equals(VERSION) ? "Versions" : "Messages")
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(List.of(INBOX, EVENT).contains(join.getRightTable()) ? join.getRightTable() + "_children" : join.getLeftTable() + "_children")
.withLabel(join.getRightTable().equals(INBOX) ? "Webhook Inbox" : join.getRightTable().equals(EVENT) ? "Events" : join.getRightTable().equals(ATTEMPT) ? "Attempts" : join.getRightTable().equals(VERSION) ? "Versions" : "Messages")
.withMaxRows(50).getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
}
return (widgets);
@@ -21,15 +21,10 @@
package com.kingsrook.qbits.email.outbox;
import java.util.List;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
/*******************************************************************************
** Minimal part 1 layout. Markdown raw HTML and unsafe URL schemes are disabled.
** Branded ad-hoc rendering. Markdown raw HTML and unsafe URL schemes are disabled.
*******************************************************************************/
public class AdHocEmailRenderer implements EmailRenderer
{
@@ -39,36 +34,22 @@ public class AdHocEmailRenderer implements EmailRenderer
@Override
public Rendered render(EmailRequest request)
{
String html = request.getHtmlBody();
if(html == null)
try
{
if(request.getMarkdownBody() != null)
var brand = com.kingsrook.qbits.email.render.EmailBrands.resolve(request.getBrandName(), null);
String html = request.getHtmlBody();
if(html == null)
{
var extensions = List.of(TablesExtension.create(), AutolinkExtension.create());
html = HtmlRenderer.builder().escapeHtml(true).sanitizeUrls(true).extensions(extensions).build()
.render(Parser.builder().extensions(extensions).build().parse(request.getMarkdownBody()));
}
else
{
html = "<pre>" + escape(request.getTextBody()) + "</pre>";
String body = request.getMarkdownBody() == null ? "<pre>" + com.kingsrook.qbits.email.template.EmailTemplateEngine.escape(request.getTextBody()) + "</pre>"
: com.kingsrook.qbits.email.render.EmailMarkdown.render(request.getMarkdownBody(), brand);
html = com.kingsrook.qbits.email.render.EmailLayout.render(body, "", brand,
request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory(), "");
}
return (new Rendered(html, request.getTextBody() == null ? com.kingsrook.qbits.email.render.HtmlToText.convert(html) : request.getTextBody()));
}
String text = request.getTextBody();
if(text == null)
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
text = html.replaceAll("(?is)<(script|style)[^>]*>.*?</\\1>", "")
.replaceAll("(?i)<br\\s*/?>|</(p|div|li|h[1-6]|tr)>", "\n")
.replaceAll("<[^>]+>", "").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"")
.replace("&#39;", "'").replace("&nbsp;", " ").replace("&amp;", "&").strip();
throw (new IllegalArgumentException("Email brand is unavailable"));
}
return (new Rendered("<!doctype html><html><body><main>" + html + "</main></body></html>", text));
}
/*******************************************************************************
** Escape plain text for the fallback HTML part.
*******************************************************************************/
private static String escape(String text)
{
return (text == null ? "" : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
}
}
@@ -31,7 +31,7 @@ import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
/*******************************************************************************
** Synchronous enqueue request. Templates and classified variables arrive in part 2.
** Synchronous enqueue request with explicit record provenance.
*******************************************************************************/
public class EmailRequest
{
@@ -48,7 +48,7 @@ public class EmailRequest
private String templateName;
private String brandName;
private String locale = "en";
private EmailCategory category = EmailCategory.TRANSACTIONAL;
private EmailCategory category;
private String idempotencyKey;
private Instant sendAfter;
private String linkedTableName;
@@ -57,6 +57,7 @@ public class EmailRequest
private String senderProcess;
private QBackendTransaction transaction;
private String transactionBackendName;
private Map<String, RecordVariable> recordVariables = new LinkedHashMap<>();
private Map<String, Serializable> variables = new LinkedHashMap<>();
private Map<String, Serializable> securityValues = new LinkedHashMap<>();
@@ -118,7 +119,7 @@ public class EmailRequest
}
/*******************************************************************************
** Accept template variables as a seam; part 1 refuses variable-bearing requests.
** Bind one schema variable.
*******************************************************************************/
public EmailRequest withVariable(String name, Serializable value)
{
@@ -131,7 +132,7 @@ public class EmailRequest
*******************************************************************************/
public EmailRequest withRecordVariable(String name, String table, Serializable id)
{
variables.put(name, new LinkedHashMap<>(Map.of("table", table, "id", id)));
recordVariables.put(name, new RecordVariable(table, id));
return (this);
}
@@ -590,4 +591,19 @@ public class EmailRequest
this.securityValues = securityValues;
return (this);
}
/*******************************************************************************
** A record reference is distinct from an ordinary JSON object.
*******************************************************************************/
public record RecordVariable(String table, Serializable id)
{
}
/*******************************************************************************
** Record bindings, read under the sender session by the service.
*******************************************************************************/
public Map<String, RecordVariable> getRecordVariables()
{
return (recordVariables);
}
}
@@ -36,8 +36,10 @@ import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -64,7 +66,7 @@ public final class EmailService
}
/*******************************************************************************
** Queue with the part 1 renderer.
** Queue validated, rendered alternatives with optional versioned templates.
*******************************************************************************/
public static EmailSendResult send(EmailRequest request) throws QException
{
@@ -72,9 +74,17 @@ public final class EmailService
}
/*******************************************************************************
** Queue with a renderer supplied by the host or a later phase.
** Queue with a host-supplied ad-hoc renderer; templates always use the validated engine.
*******************************************************************************/
public static EmailSendResult send(EmailRequest request, EmailRenderer renderer) throws QException
{
return (send(request, renderer, null));
}
/*******************************************************************************
** Shared enqueue path, including already protected retained content.
*******************************************************************************/
private static EmailSendResult send(EmailRequest request, EmailRenderer renderer, QRecord retained) throws QException
{
EmailQBitConfig config = EmailRuntime.config();
if(config == null)
@@ -89,20 +99,28 @@ public final class EmailService
{
return (refused("An email request is required"));
}
if(request.getTemplateName() != null)
EmailTemplateRenderer templateRenderer;
QRecord templateContent;
try
{
return (refused("templates arrive in part 2"));
templateRenderer = request.getTemplateName() == null ? null : EmailTemplateRenderer.prepare(request);
templateContent = templateRenderer == null ? retained : templateRenderer.render("pending", "https://example.invalid/unsubscribe/pending", true);
}
if(!request.getVariables().isEmpty() || request.getBrandName() != null)
catch(IllegalArgumentException e)
{
return (refused("Template variables and branding arrive in part 2"));
return (refused(e.getMessage()));
}
if(request.getSubject() == null || request.getSubject().contains("\r") || request.getSubject().contains("\n")
|| (request.getMarkdownBody() == null && request.getHtmlBody() == null && request.getTextBody() == null))
if(templateRenderer == null && (!request.getVariables().isEmpty() || !request.getRecordVariables().isEmpty()))
{
return (refused("Variables require a template with a schema"));
}
if(templateRenderer == null && (request.getSubject() == null || request.getSubject().contains("\r") || request.getSubject().contains("\n")
|| (request.getMarkdownBody() == null && request.getHtmlBody() == null && request.getTextBody() == null)))
{
return (refused("A subject and message body are required; subject must be one line"));
}
EmailCategory category = request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory();
EmailCategory category = templateContent == null ? (request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory())
: EmailCategory.valueOf(templateContent.getValueString("category"));
QBackendTransaction transaction = request.getTransaction();
boolean sameBackend = transaction == null || request.getTransactionBackendName() == null || config.getBackendName().equals(request.getTransactionBackendName());
QBackendTransaction queryTransaction = sameBackend ? transaction : null;
@@ -125,6 +143,22 @@ public final class EmailService
{
return (new EmailSendResult(null, EmailSendResult.Status.REFUSED, providerErrors));
}
try
{
if(templateRenderer != null)
{
templateRenderer.validateProvider(provider);
}
else if(retained != null && Boolean.TRUE.equals(retained.getValueBoolean("phiApproved")))
{
EmailTemplateRenderer.checkProvider(provider);
com.kingsrook.qbits.email.phi.EmailBodyEncryption.encrypt(null, "subject", "pending");
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
List<String> to;
List<String> cc;
List<String> bcc;
@@ -154,6 +188,18 @@ public final class EmailService
{
return (refused("One or more email addresses are invalid"));
}
try
{
String unsubscribe = EmailUnsubscribeLinks.create("pending", String.join(",", to), category.name());
if(unsubscribe != null || retained != null && retained.getValueString("unsubscribeUrl") != null)
{
EmailUnsubscribeLinks.recipient(String.join(",", to), String.join(",", cc), String.join(",", bcc));
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
if(to.isEmpty() && cc.isEmpty() && bcc.isEmpty())
{
return (refused("At least one recipient is required"));
@@ -180,18 +226,36 @@ public final class EmailService
});
}
boolean allSuppressed = to.isEmpty() && cc.isEmpty() && bcc.isEmpty();
EmailRenderer.Rendered rendered = renderer.render(request);
EmailRenderer.Rendered rendered;
QRecord adHocBrand = null;
try
{
rendered = templateContent == null ? renderer.render(request) : new EmailRenderer.Rendered(templateContent.getValueString("htmlBody"), templateContent.getValueString("textBody"));
if(templateContent == null)
{
adHocBrand = com.kingsrook.qbits.email.render.EmailBrands.resolve(request.getBrandName(), null);
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
Instant now = EmailRuntime.clock().instant();
QRecord row = new QRecord().withValue("providerId", provider.getValue("id"))
.withValue("status", allSuppressed ? "SUPPRESSED" : "QUEUED").withValue("finishedDate", allSuppressed ? now : null).withValue("attemptCount", 0)
.withValue("toAddresses", String.join(",", to)).withValue("ccAddresses", String.join(",", cc)).withValue("bccAddresses", String.join(",", bcc))
.withValue("fromAddress", from).withValue("replyTo", replyTo).withValue("subject", request.getSubject())
.withValue("htmlBody", rendered.html()).withValue("textBody", rendered.text()).withValue("bodySha256", digest(rendered))
.withValue("brandId", adHocBrand == null ? null : adHocBrand.getValue("id"))
.withValue("category", category.name()).withValue("locale", request.getLocale()).withValue("idempotencyKey", request.getIdempotencyKey())
.withValue("linkedTableName", request.getLinkedTableName()).withValue("linkedRecordId", request.getLinkedRecordId())
.withValue("senderQbit", request.getSenderQbit()).withValue("senderProcess", request.getSenderProcess())
.withValue("sendAfter", request.getSendAfter()).withValue("nextAttemptDate", request.getSendAfter() == null ? now : request.getSendAfter())
.withValue("suppressedAddresses", String.join(",", dropped));
if(templateContent != null)
{
row.getValues().putAll(templateContent.getValues());
}
for(var field : config.getSecurityFields())
{
row.setValue(field.getName(), request.getSecurityValues().get(field.getName()));
@@ -202,7 +266,7 @@ public final class EmailService
{
try
{
if(insert(row, null).status() == EmailSendResult.Status.REFUSED)
if(insert(row, null, templateRenderer).status() == EmailSendResult.Status.REFUSED)
{
throw (new QException("Email after-commit enqueue failed"));
}
@@ -216,21 +280,70 @@ public final class EmailService
return (new EmailSendResult(null, allSuppressed ? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED,
List.of("Enqueue deferred until caller transaction commits")));
}
return (insert(row, transaction));
return (insert(row, transaction, templateRenderer));
}
/*******************************************************************************
** Resend retained content without decrypting it or downgrading PHI policy.
** Snapshot tokens keep the original binding; the new row links to that row.
*******************************************************************************/
public static EmailSendResult resend(Integer id) throws QException
{
if(!SystemWrites.active() && !EmailPermissionChecker.has(EmailPermissions.MANAGE))
{
return (refused("Resending email needs email.manage"));
}
QRecord row = EmailStore.get(EmailTables.MESSAGE, id, null);
if(row == null || row.getValueString("htmlBody") == null && row.getValueString("textBody") == null)
{
return (refused("Retained message content is unavailable"));
}
QRecord provider = EmailStore.get(EmailTables.PROVIDER, row.getValueInteger("providerId"), null);
if(provider == null)
{
return (refused("Provider is unavailable"));
}
EmailRequest request = new EmailRequest().withSubject(row.getValueString("subject")).withHtmlBody(row.getValueString("htmlBody"))
.withTextBody(row.getValueString("textBody")).withFrom(row.getValueString("fromAddress")).withReplyTo(row.getValueString("replyTo"))
.withProviderName(provider.getValueString("name")).withCategory(EmailCategory.valueOf(row.getValueString("category")))
.withLinkedRecord(EmailTables.MESSAGE, id).withSender("email", "email_resend");
if(row.getValueString("toAddresses") != null && !row.getValueString("toAddresses").isBlank())
{
request.withTo(row.getValueString("toAddresses"));
}
if(row.getValueString("ccAddresses") != null && !row.getValueString("ccAddresses").isBlank())
{
request.withCc(row.getValueString("ccAddresses"));
}
if(row.getValueString("bccAddresses") != null && !row.getValueString("bccAddresses").isBlank())
{
request.withBcc(row.getValueString("bccAddresses"));
}
for(var field : EmailRuntime.config().getSecurityFields())
{
request.getSecurityValues().put(field.getName(), row.getValue(field.getName()));
}
QRecord content = new QRecord();
for(String field : List.of("subject", "htmlBody", "textBody", "variablesSnapshot", "phiApproved", "templateId", "templateVersionId", "brandId", "locale", "category", "unsubscribeUrl"))
{
content.setValue(field, row.getValue(field));
}
content.withValue("encryptionSourceId", row.getValueString("id"));
return (send(request, ignored -> new EmailRenderer.Rendered(row.getValueString("htmlBody"), row.getValueString("textBody")), content));
}
/*******************************************************************************
** Insert once; the database unique key arbitrates concurrent requests.
*******************************************************************************/
private static EmailSendResult insert(QRecord row, QBackendTransaction caller) throws QException
private static EmailSendResult insert(QRecord row, QBackendTransaction caller, EmailTemplateRenderer templateRenderer) throws QException
{
if(caller != null)
{
return (insertInTransaction(row, caller));
return (insertInTransaction(row, caller, templateRenderer));
}
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
{
EmailSendResult result = insertInTransaction(row, transaction);
EmailSendResult result = insertInTransaction(row, transaction, templateRenderer);
if(result.status() == EmailSendResult.Status.REFUSED)
{
transaction.rollback();
@@ -248,7 +361,7 @@ public final class EmailService
/*******************************************************************************
** Insert using the caller transaction, returning only safe diagnostics.
*******************************************************************************/
private static EmailSendResult insertInTransaction(QRecord row, QBackendTransaction transaction) throws QException
private static EmailSendResult insertInTransaction(QRecord row, QBackendTransaction transaction, EmailTemplateRenderer templateRenderer) throws QException
{
// A shared provider-row gate prevents a duplicate-key failure from aborting
// a caller-owned SQL transaction. The unique index remains the final arbiter.
@@ -278,8 +391,53 @@ public final class EmailService
{
return (refused("Could not queue the email message"));
}
try
{
String unsubscribe = row.getValueString("unsubscribeUrl");
if(unsubscribe == null && !"SUPPRESSED".equals(row.getValueString("status"))
&& (templateRenderer != null || !Boolean.TRUE.equals(row.getValueBoolean("phiApproved"))))
{
unsubscribe = EmailUnsubscribeLinks.create(inserted.getValueString("id"), row.getValueString("toAddresses"), row.getValueString("category"));
}
QRecord content;
if(templateRenderer != null)
{
content = templateRenderer.render(inserted.getValueString("id"), unsubscribe, true);
}
else
{
content = new QRecord();
if(Boolean.TRUE.equals(row.getValueBoolean("phiApproved")))
{
for(String field : List.of("subject", "htmlBody", "textBody"))
{
content.setValue(field, com.kingsrook.qbits.email.phi.EmailBodyEncryption.rebind(row.getValueString(field), field,
row.getValueString("encryptionSourceId"), inserted.getValueString("id")));
}
}
if(unsubscribe != null && row.getValueString("unsubscribeUrl") == null)
{
String link = "<p><a href=\"" + com.kingsrook.qbits.email.template.EmailTemplateEngine.escape(unsubscribe) + "\">Unsubscribe</a></p>";
String html = row.getValueString("htmlBody");
java.util.regex.Matcher end = java.util.regex.Pattern.compile("</footer>|</body>", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(html);
content.withValue("htmlBody", end.find() ? html.substring(0, end.start()) + link + html.substring(end.start()) : html + link)
.withValue("textBody", row.getValueString("textBody") + "\nUnsubscribe (" + unsubscribe + ")");
}
}
content.withValue("id", inserted.getValue("id")).withValue("unsubscribeUrl", unsubscribe);
if(content.getValueString("htmlBody") != null)
{
content.withValue("bodySha256", digest(new EmailRenderer.Rendered(content.getValueString("htmlBody"), content.getValueString("textBody"))));
}
EmailStore.update(EmailTables.MESSAGE, content, transaction);
}
catch(IllegalArgumentException e)
{
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", inserted.getValueInteger("id")), transaction);
return (refused(e.getMessage()));
}
return (new EmailSendResult(inserted.getValueInteger("id"), "SUPPRESSED".equals(row.getValueString("status"))
? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED, List.of()));
? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED, templateRenderer == null ? List.of() : templateRenderer.warnings()));
}
/*******************************************************************************
@@ -0,0 +1,137 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import com.kingsrook.qbits.email.EmailRuntime;
/*******************************************************************************
** Stateless AES-256-GCM body protection. Only the dispatcher decrypts content.
*******************************************************************************/
public final class EmailBodyEncryption
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBodyEncryption()
{
}
/*******************************************************************************
** Resolve the deployment key, never retaining it between operations.
*******************************************************************************/
private static SecretKeySpec key() throws Exception
{
String secret = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_BODY_KEY");
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
return (new SecretKeySpec(MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)), "AES"));
}
/*******************************************************************************
** Encrypt with a fresh 96-bit nonce and 128-bit authentication tag.
*******************************************************************************/
public static String encrypt(String value, String column, String messageId)
{
try
{
SecretKeySpec key = key();
if(value == null)
{
return (null);
}
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
return ("enc:v1:" + Base64.getUrlEncoder().withoutPadding().encodeToString(ByteBuffer.allocate(iv.length + encrypted.length).put(iv).put(encrypted).array()));
}
catch(Exception e)
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
}
/*******************************************************************************
** Authenticate before exposing plaintext. Failures never echo ciphertext.
*******************************************************************************/
public static String decrypt(String value, String column, String messageId)
{
if(value == null || !value.startsWith("enc:v1:"))
{
return (value);
}
try
{
ByteBuffer bytes = ByteBuffer.wrap(Base64.getUrlDecoder().decode(value.substring(7)));
byte[] iv = new byte[12];
bytes.get(iv);
byte[] encrypted = new byte[bytes.remaining()];
bytes.get(encrypted);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
return (new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Encrypted email body could not be opened"));
}
}
/*******************************************************************************
** Bind ciphertext to its storage table, column and message identity.
*******************************************************************************/
private static byte[] aad(String column, String messageId)
{
String storageColumn = switch(column)
{
case "subject" -> "subject";
case "htmlBody" -> "html_body";
case "textBody" -> "text_body";
default -> throw (new IllegalArgumentException("Unknown protected email column"));
};
return (("email_message\n" + storageColumn + "\n" + java.util.Objects.requireNonNull(messageId)).getBytes(StandardCharsets.UTF_8));
}
/*******************************************************************************
** Authenticate and rebind retained ciphertext without exposing plaintext to
** the resend caller. Only delivery callers receive decrypted content.
*******************************************************************************/
public static String rebind(String value, String column, String oldMessageId, String newMessageId)
{
return (encrypt(decrypt(value, column, oldMessageId), column, newMessageId));
}
}
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** STAND-IN(core=#1142, removeBy=RC.7). Core classification wins when available;
** overrides then schema declarations provide the RC.5 compatibility contract.
*******************************************************************************/
public final class EmailClassifications
{
/*******************************************************************************
** Ordered data sensitivity.
*******************************************************************************/
public enum Level
{
PUBLIC, CONFIDENTIAL, PII, PHI
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailClassifications()
{
}
/*******************************************************************************
** Resolve a field without a compile-time dependency on unreleased core.
*******************************************************************************/
public static Level ofField(QTableMetaData table, String field, String declared, EmailQBitConfig config)
{
if(table != null && field != null)
{
try
{
Class<?> core = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications");
Object value = core.getMethod("ofField", QTableMetaData.class, String.class).invoke(null, table, field);
if(value instanceof Enum<?> level)
{
return (Level.valueOf(level.name()));
}
}
catch(ClassNotFoundException e)
{
// Expected on RC.5; use the explicit stand-in configuration.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
throw (new IllegalArgumentException("Field classification is unavailable"));
}
Level override = config.getClassificationOverrides().get(table.getName() + "." + field);
if(override != null)
{
return (override);
}
}
return (declared == null ? Level.PUBLIC : Level.valueOf(declared));
}
/*******************************************************************************
** Schema declarations alone cannot attest a record's classification source.
*******************************************************************************/
public static boolean hasSource(QTableMetaData table, String field, EmailQBitConfig config)
{
if(config.getClassificationOverrides().containsKey(table.getName() + "." + field))
{
return (true);
}
try
{
Class<?> core = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications");
return (core.getMethod("ofField", QTableMetaData.class, String.class).invoke(null, table, field) instanceof Enum<?>);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** PII has the same email approval requirement as PHI.
*******************************************************************************/
public static boolean sensitive(Level level)
{
return (level == Level.PII || level == Level.PHI);
}
}
@@ -0,0 +1,149 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.Serializable;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** STAND-IN(core=#1161-impl, removeBy=RC.7). Mirrors ClassifiedValueTokens.
** Tokens are comparisons, never recoverable snapshots. No secret is retained.
*******************************************************************************/
public final class EmailValueTokens
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailValueTokens()
{
}
/*******************************************************************************
** Bind to table, field and record identity. TODO core: wipe-rule record
** fields will produce shred:v1; context values must never use shred:v1.
*******************************************************************************/
public static String forRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value)
{
return (token(List.of(table.getName(), field.getName(), String.valueOf(pk)), value));
}
/*******************************************************************************
** Non-record low-entropy inputs cannot yield dictionary-testable tokens.
*******************************************************************************/
public static String forContext(List<String> bindingParts, Serializable value)
{
if(value instanceof Boolean || canonical(value).codePoints().distinct().count() < 4)
{
return (changed());
}
return (token(bindingParts, value));
}
/*******************************************************************************
** Deliberately non-verifiable marker.
*******************************************************************************/
public static String changed()
{
return ("changed:v1");
}
/*******************************************************************************
** Verify only a genuine keyed token, with constant-time comparison.
*******************************************************************************/
public static boolean verifyRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value, String token)
{
return (matches(token, forRecordField(table, field, pk, value)));
}
/*******************************************************************************
** Verify a context token, never changed or shred markers.
*******************************************************************************/
public static boolean verifyContext(List<String> bindingParts, Serializable value, String token)
{
return (matches(token, forContext(bindingParts, value)));
}
/*******************************************************************************
** Stable canonical representation shared with future core.
*******************************************************************************/
public static String canonical(Serializable value)
{
if(value instanceof BigDecimal decimal)
{
return (decimal.stripTrailingZeros().toPlainString());
}
return (ValueUtils.getValueAsString(value));
}
/*******************************************************************************
** Construct the versioned HMAC without logging key or input.
*******************************************************************************/
private static String token(List<String> parts, Serializable value)
{
try
{
String secret = EmailRuntime.config().getSecretResolver().apply("QQQ_AUDIT_HMAC_KEY");
if(secret == null || secret.isBlank())
{
return (changed());
}
byte[] key = Base64.getDecoder().decode(secret);
String keyId = EmailRuntime.config().getSecretResolver().apply("QQQ_AUDIT_HMAC_KEY_ID");
if(keyId == null || keyId.isBlank())
{
keyId = "env1";
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
List<String> binding = new ArrayList<>(parts);
binding.add(canonical(value));
return ("hmac:v1:" + keyId + ":" + Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(String.join("\u001f", binding).getBytes(StandardCharsets.UTF_8))));
}
catch(Exception e)
{
// An unavailable or malformed audit key must not reveal the value.
return (changed());
}
}
/*******************************************************************************
** Compare only HMAC tokens, never sentinel strings.
*******************************************************************************/
private static boolean matches(String supplied, String computed)
{
return (supplied != null && supplied.startsWith("hmac:v1:") && computed.startsWith("hmac:v1:")
&& MessageDigest.isEqual(supplied.getBytes(StandardCharsets.UTF_8), computed.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -33,7 +33,6 @@ import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRenderer;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
@@ -76,13 +75,19 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
*******************************************************************************/
public final class EmailProcesses
{
public static final String NEW_VERSION = "email_new_version";
public static final String ACTIVATE_VERSION = "email_activate_version";
public static final String APPROVE_PHI = "email_approve_phi";
public static final String RESEND = "email_resend";
public static final String CANCEL = "email_cancel";
public static final String SEND_NOW = "email_send_now";
public static final String SEND_TEST = "email_send_test";
public static final String SEND_AD_HOC = "email_send_ad_hoc";
public static final String SEND_TEMPLATED = "email_send_templated";
public static final String DISPATCH = "email_dispatch";
public static final String MAINTAIN = "email_maintain";
public static final String REPROCESS = "email_reprocess_webhook";
public static final String PROCESS_WEBHOOKS = "email_process_webhooks";
public static final String RETENTION = "email_retention";
/*******************************************************************************
@@ -99,8 +104,8 @@ public final class EmailProcesses
{
return (switch(name)
{
case RESEND, CANCEL, SEND_NOW -> EmailPermissions.MANAGE;
case SEND_TEMPLATED -> EmailPermissions.SEND;
case REPROCESS, RESEND, CANCEL, SEND_NOW, NEW_VERSION, ACTIVATE_VERSION -> EmailPermissions.MANAGE;
case SEND_TEMPLATED, SEND_AD_HOC -> EmailPermissions.SEND;
default -> EmailPermissions.ADMIN;
});
}
@@ -115,16 +120,25 @@ public final class EmailProcesses
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("toAddress", QFieldType.STRING).withLabel("To address").withIsRequired(true)));
QFunctionInputMetaData inputs = new QFunctionInputMetaData();
QFunctionInputMetaData adHocInputs = new QFunctionInputMetaData();
for(String field : List.of("templateName", "to", "cc", "bcc", "replyTo", "subject", "markdownBody", "htmlBody", "textBody", "providerName", "brandName",
"category", "variablesJson", "linkedTableName", "linkedRecordId", "idempotencyKey"))
"locale", "variablesJson", "recordVariablesJson", "linkedTableName", "linkedRecordId", "idempotencyKey"))
{
inputs.withField(new QFieldMetaData(field, QFieldType.STRING));
adHocInputs.withField(new QFieldMetaData(field, QFieldType.STRING));
}
adHocInputs.withField(new QFieldMetaData("category", QFieldType.STRING));
QProcessMetaData adHoc = new QProcessMetaData().withName(SEND_AD_HOC).withLabel("Send Ad Hoc Email").withIsHidden(true)
.withPermissionRules(EmailTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("send").withCode(new QCodeReference(SendStep.class)).withInputData(adHocInputs));
QProcessMetaData send = new QProcessMetaData().withName(SEND_TEMPLATED).withLabel("Send Email").withIsHidden(true)
.withPermissionRules(EmailTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("send").withCode(new QCodeReference(SendStep.class)).withInputData(inputs));
return (List.of(action(RESEND, "Resend", EmailTables.MESSAGE), action(CANCEL, "Cancel", EmailTables.MESSAGE),
action(SEND_NOW, "Send Now", EmailTables.MESSAGE), test, send,
action(SEND_NOW, "Send Now", EmailTables.MESSAGE), test, send, adHoc,
action(NEW_VERSION, "New Version", EmailTables.TEMPLATE), action(ACTIVATE_VERSION, "Activate Version", EmailTables.VERSION),
action(APPROVE_PHI, "Approve For PHI", EmailTables.VERSION),
action(REPROCESS, "Reprocess", EmailTables.INBOX), scheduled(config, PROCESS_WEBHOOKS, "Process Email Webhooks", 30),
scheduled(config, DISPATCH, "Dispatch Email", config.getDispatchIntervalSeconds()),
scheduled(config, MAINTAIN, "Maintain Email", 300), scheduled(config, RETENTION, "Email Retention", 86400)));
}
@@ -164,6 +178,18 @@ public final class EmailProcesses
*******************************************************************************/
private static boolean eligible(String process, QRecord row)
{
if(REPROCESS.equals(process))
{
return ("FAILED".equals(row.getValueString("status")));
}
if(NEW_VERSION.equals(process))
{
return (true);
}
if(ACTIVATE_VERSION.equals(process) || APPROVE_PHI.equals(process))
{
return ("DRAFT".equals(row.getValueString("status")) || APPROVE_PHI.equals(process) && "ACTIVE".equals(row.getValueString("status")));
}
if(SEND_TEST.equals(process))
{
return (Boolean.TRUE.equals(row.getValueBoolean("enabled")));
@@ -188,12 +214,30 @@ public final class EmailProcesses
** Filter rows and explain skipped records.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output)
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
boolean valid = eligible(input.getProcessName(), row);
lines.add(new ProcessSummaryLine(valid ? Status.OK : Status.INFO, 1, valid ? "record will be processed" : "record skipped: status or retained content does not permit this action"));
String diagnostic = valid ? "record will be processed" : "record skipped: status or retained content does not permit this action";
if(valid && List.of(NEW_VERSION, ACTIVATE_VERSION, APPROVE_PHI).contains(input.getProcessName()))
{
try
{
QRecord template = NEW_VERSION.equals(input.getProcessName()) ? row : EmailStore.get(EmailTables.TEMPLATE, row.getValueInteger("templateId"), null);
com.kingsrook.qbits.email.template.EmailVersions.requireEdit(template, APPROVE_PHI.equals(input.getProcessName()));
if(ACTIVATE_VERSION.equals(input.getProcessName()))
{
com.kingsrook.qbits.email.template.EmailVersions.validate(template, row);
}
}
catch(IllegalArgumentException e)
{
valid = false;
diagnostic = e.getMessage();
}
}
lines.add(new ProcessSummaryLine(valid ? Status.OK : Status.INFO, 1, diagnostic));
if(valid)
{
output.getRecords().add(row);
@@ -228,12 +272,25 @@ public final class EmailProcesses
for(QRecord row : input.getRecords())
{
Integer id = row.getValueInteger("id");
QRecord current = EmailStore.get(SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
QRecord current = EmailStore.get(NEW_VERSION.equals(process) ? EmailTables.TEMPLATE : List.of(ACTIVATE_VERSION, APPROVE_PHI).contains(process) ? EmailTables.VERSION
: REPROCESS.equals(process) ? EmailTables.INBOX : SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
String result = "Skipped";
if(current != null && eligible(process, current))
{
result = switch(process)
{
case NEW_VERSION -> "Created DRAFT " + com.kingsrook.qbits.email.template.EmailVersions.newVersion(id).getValueInteger("versionNumber");
case ACTIVATE_VERSION ->
{
com.kingsrook.qbits.email.template.EmailVersions.activate(id);
yield "ACTIVE";
}
case APPROVE_PHI ->
{
com.kingsrook.qbits.email.template.EmailVersions.approve(id);
yield "Approved for PHI";
}
case REPROCESS -> com.kingsrook.qbits.email.tracking.EmailInboxProcessor.reprocess(id) ? "PENDING" : "Skipped";
case SEND_NOW -> new EmailDispatcher().sendNow(id).name();
case CANCEL -> cancel(id) ? "CANCELLED" : "Skipped";
case RESEND -> resend(current).status().name();
@@ -285,29 +342,7 @@ public final class EmailProcesses
*******************************************************************************/
private static EmailSendResult resend(QRecord row) throws QException
{
QRecord provider = EmailStore.get(EmailTables.PROVIDER, row.getValueInteger("providerId"), null);
EmailRequest request = new EmailRequest().withSubject(row.getValueString("subject")).withHtmlBody(row.getValueString("htmlBody"))
.withTextBody(row.getValueString("textBody")).withFrom(row.getValueString("fromAddress")).withReplyTo(row.getValueString("replyTo"))
.withProviderName(provider.getValueString("name")).withCategory(EmailCategory.valueOf(row.getValueString("category")))
.withLinkedRecord(EmailTables.MESSAGE, row.getValue("id")).withSender("email", RESEND);
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
{
String value = row.getValueString(field);
if(value != null && !value.isBlank())
{
switch(field)
{
case "toAddresses" -> request.withTo(value);
case "ccAddresses" -> request.withCc(value);
default -> request.withBcc(value);
}
}
}
for(var field : EmailRuntime.config().getSecurityFields())
{
request.getSecurityValues().put(field.getName(), row.getValue(field.getName()));
}
return (EmailService.send(request, ignored -> new EmailRenderer.Rendered(row.getValueString("htmlBody"), row.getValueString("textBody"))));
return (EmailService.resend(row.getValueInteger("id")));
}
/*******************************************************************************
@@ -355,9 +390,25 @@ public final class EmailProcesses
}
if(input.getValueString("variablesJson") != null)
{
request.withVariable("variablesJson", input.getValueString("variablesJson"));
request.withVariables(com.kingsrook.qbits.email.template.VariableSchema.values(input.getValueString("variablesJson")));
}
if(input.getValueString("category") != null)
if(input.getValueString("locale") != null)
{
request.withLocale(input.getValueString("locale"));
}
if(input.getValueString("recordVariablesJson") != null)
{
var bindings = com.kingsrook.qbits.email.template.VariableSchema.values(input.getValueString("recordVariablesJson"));
for(var binding : bindings.entrySet())
{
if(!(binding.getValue() instanceof Map<?, ?> ref) || !(ref.get("table") instanceof String table) || !(ref.get("id") instanceof Serializable id))
{
throw (new QException("Record variables need table and id"));
}
request.withRecordVariable(binding.getKey(), table, id);
}
}
if(request.getTemplateName() == null && input.getValueString("category") != null)
{
try
{
@@ -388,6 +439,7 @@ public final class EmailProcesses
{
switch(input.getProcessName())
{
case PROCESS_WEBHOOKS -> new com.kingsrook.qbits.email.tracking.EmailInboxProcessor().runOnce();
case RETENTION -> retain();
case MAINTAIN -> new EmailDispatcher().maintain();
default -> new EmailDispatcher().runOnce();
@@ -397,6 +449,8 @@ public final class EmailProcesses
/*******************************************************************************
** Clear retained bodies and optionally delete final messages and their attempts.
** NOTIFICATION and MARKETING messages are never deleted (their unsubscribe links must
** keep working); at message retention only their bodies and variable snapshot are cleared.
*******************************************************************************/
public static void retain() throws QException
{
@@ -421,18 +475,35 @@ public final class EmailProcesses
{
continue;
}
if(config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays()))))
boolean expired = config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays())));
boolean optOut = List.of("NOTIFICATION", "MARKETING").contains(row.getValueString("category"));
if(expired && !optOut)
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(EmailTables.MESSAGE)))
{
if(row.getValueInteger("providerId") != null)
{
com.kingsrook.qbits.email.tracking.EmailTrackingStore.lock(row.getValueInteger("providerId"), transaction);
}
SystemWrites.delete(EmailTables.EVENT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
SystemWrites.delete(EmailTables.ATTEMPT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", row.getValueInteger("id")), transaction);
transaction.commit();
}
}
else if(finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))) && (row.getValue("htmlBody") != null || row.getValue("textBody") != null))
else if(expired || finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))))
{
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null));
// Notification and marketing rows are never deleted, so their unsubscribe links keep working; only content goes.
boolean clearSnapshot = expired && row.getValue("variablesSnapshot") != null;
if(row.getValue("htmlBody") != null || row.getValue("textBody") != null || clearSnapshot)
{
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null);
if(clearSnapshot)
{
update.withValue("variablesSnapshot", null);
}
SystemWrites.updateOne(EmailTables.MESSAGE, update);
}
}
}
}
@@ -0,0 +1,112 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** WCAG relative luminance and AA contrast for both email color schemes.
*******************************************************************************/
public final class ContrastCheck
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ContrastCheck()
{
}
/*******************************************************************************
** Check every actual foreground/background pair in the branded shell.
*******************************************************************************/
public static void validate(QRecord brand)
{
check("Text on background", color(brand, "textColor"), color(brand, "backgroundColor"), "light");
check("Text on background", color(brand, "darkTextColor"), color(brand, "darkBackgroundColor"), "dark");
check("Button text on primary", color(brand, "buttonTextColor"), color(brand, "primaryColor"), "light");
check("Button text on primary", color(brand, "darkButtonTextColor"), color(brand, "darkPrimaryColor"), "dark");
}
/*******************************************************************************
** Defaults are neutral and accessible; reject anything except hex colors.
*******************************************************************************/
public static String color(QRecord brand, String field)
{
String value = brand.getValueString(field);
if(value == null || value.isBlank())
{
value = switch(field)
{
case "backgroundColor", "buttonTextColor", "darkTextColor" -> "#ffffff";
case "primaryColor" -> "#2459a6";
case "darkPrimaryColor" -> color(brand, "primaryColor");
case "darkButtonTextColor" -> ratio(color(brand, "buttonTextColor"), color(brand, "darkPrimaryColor")) >= 4.5
? color(brand, "buttonTextColor") : ratio("#000000", color(brand, "darkPrimaryColor")) >= 4.5 ? "#000000" : "#ffffff";
default -> "#171717";
};
}
if(!value.matches("#[0-9a-fA-F]{6}"))
{
throw (new IllegalArgumentException("Brand colors must use six-digit hex values"));
}
return (value);
}
/*******************************************************************************
** Contrast is independent of which color is lighter.
*******************************************************************************/
public static double ratio(String first, String second)
{
double a = luminance(first);
double b = luminance(second);
return ((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05));
}
/*******************************************************************************
** sRGB linearization and weighted luminance.
*******************************************************************************/
private static double luminance(String color)
{
double[] weights = {0.2126, 0.7152, 0.0722};
double result = 0;
for(int i = 0; i < 3; i++)
{
double channel = Integer.parseInt(color.substring(1 + i * 2, 3 + i * 2), 16) / 255.0;
result += weights[i] * (channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4));
}
return (result);
}
/*******************************************************************************
** Author-facing refusal explains the failing scheme and threshold.
*******************************************************************************/
private static void check(String label, String foreground, String background, String scheme)
{
double ratio = ratio(foreground, background);
if(ratio < 4.5)
{
throw (new IllegalArgumentException(String.format(Locale.ROOT, "%s is %.1f:1 in %s mode; it needs 4.5:1", label, ratio, scheme)));
}
}
}
@@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.List;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Brand selection shared by sends, activation and previews.
*******************************************************************************/
public final class EmailBrands
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBrands()
{
}
/*******************************************************************************
** Explicit request, template, app brand, default brand, then neutral.
*******************************************************************************/
public static QRecord resolve(String requested, QRecord template) throws QException
{
if(requested != null)
{
List<QRecord> rows = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("name", requested));
if(rows.isEmpty())
{
throw (new IllegalArgumentException("Unknown email brand"));
}
return (rows.getFirst());
}
if(template != null && template.getValueInteger("brandId") != null)
{
QRecord brand = EmailStore.get(EmailTables.BRAND, template.getValueInteger("brandId"), null);
if(brand == null)
{
throw (new IllegalArgumentException("Template brand is missing"));
}
return (brand);
}
List<QRecord> brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("appName", EmailRuntime.config().getAppName()));
if(brands.isEmpty())
{
brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("isDefault", true));
}
if(brands.size() > 1)
{
throw (new IllegalArgumentException("More than one default email brand matches"));
}
return (brands.isEmpty() ? new QRecord().withValue("label", EmailRuntime.config().getAppLabel()).withValue("name", "neutral") : brands.getFirst());
}
/*******************************************************************************
** Refuse unsafe URLs and inaccessible colors before a brand is saved.
*******************************************************************************/
public static void validate(QRecord brand)
{
ContrastCheck.validate(brand);
for(String field : List.of("logoUrl", "logoDarkUrl", "siteUrl"))
{
String value = brand.getValueString(field);
if(value != null && !value.isBlank())
{
VariableSchema.url(value);
if(!"siteUrl".equals(field))
{
https(value);
}
}
}
social(brand);
}
/*******************************************************************************
** Parse the small social-link list with strict web URLs.
*******************************************************************************/
public static List<java.util.Map<String, String>> social(QRecord brand)
{
try
{
String json = brand.getValueString("socialLinks");
if(json == null || json.isBlank())
{
return (List.of());
}
var links = new com.fasterxml.jackson.databind.ObjectMapper().readValue(json,
new com.fasterxml.jackson.core.type.TypeReference<List<java.util.Map<String, String>>>() {});
links.forEach(link -> https(link.get("url")));
return (links);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Social links need labels and https URLs"));
}
}
/*******************************************************************************
** Logos and social destinations require HTTPS at the save boundary.
*******************************************************************************/
private static void https(String value)
{
VariableSchema.url(value);
if(!"https".equalsIgnoreCase(java.net.URI.create(value).getScheme()))
{
throw (new IllegalArgumentException("Brand logo and social URLs must use https"));
}
}
/*******************************************************************************
** Approved messages restrict every image to the brand site or an explicit host.
*******************************************************************************/
public static boolean allowedImage(String value, QRecord brand)
{
try
{
https(value);
String host = java.net.URI.create(value).getHost();
String site = brand.getValueString("siteUrl");
return (EmailRuntime.config().getAllowedImageHosts().contains(host.toLowerCase(java.util.Locale.ROOT))
|| site != null && host.equalsIgnoreCase(java.net.URI.create(site).getHost()));
}
catch(IllegalArgumentException e)
{
return (false);
}
}
}
@@ -0,0 +1,135 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Single-column, 600px email shell with inline fallbacks and dark-mode hooks.
*******************************************************************************/
public final class EmailLayout
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailLayout()
{
}
/*******************************************************************************
** Render both schemes from the same validated brand.
*******************************************************************************/
public static String render(String body, String preheader, QRecord brand, EmailCategory category, String unsubscribe)
{
EmailBrands.validate(brand);
String dark = ".email-shell,.email-content{background-color:" + ContrastCheck.color(brand, "darkBackgroundColor")
+ "!important;color:" + ContrastCheck.color(brand, "darkTextColor") + "!important}.email-button{background-color:"
+ ContrastCheck.color(brand, "darkPrimaryColor") + "!important}.email-button a{color:" + ContrastCheck.color(brand, "darkButtonTextColor")
+ "!important}.email-logo-light{display:none!important}.email-logo-dark{display:block!important}";
String outlook = "[data-ogsc] .email-content,[data-ogsc] .email-shell,[data-ogsb] .email-content,[data-ogsb] .email-shell{background-color:" + ContrastCheck.color(brand, "darkBackgroundColor")
+ "!important;color:" + ContrastCheck.color(brand, "darkTextColor") + "!important}[data-ogsc] .email-button,[data-ogsb] .email-button{background-color:"
+ ContrastCheck.color(brand, "darkPrimaryColor") + "!important}[data-ogsc] .email-button a,[data-ogsb] .email-button a{color:" + ContrastCheck.color(brand, "darkButtonTextColor")
+ "!important}[data-ogsc] .email-logo-light,[data-ogsb] .email-logo-light{display:none!important}[data-ogsc] .email-logo-dark,[data-ogsb] .email-logo-dark{display:block!important}";
return ("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<meta name=\"color-scheme\" content=\"light dark\"><meta name=\"supported-color-schemes\" content=\"light dark\">"
+ "<style>:root{color-scheme:light dark;supported-color-schemes:light dark}@media(max-width:640px){.email-content{padding:16px!important;width:100%!important}}"
+ "@media(prefers-color-scheme:dark){" + dark + "}" + outlook + "</style></head><body class=\"email-shell\" style=\"margin:0;padding:0;background-color:"
+ ContrastCheck.color(brand, "backgroundColor") + ";color:" + ContrastCheck.color(brand, "textColor") + "\">"
+ "<div style=\"display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all\">" + EmailTemplateEngine.escape(preheader) + "</div>"
+ "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr><td align=\"center\">"
+ "<!--[if mso]><table role=\"presentation\" width=\"600\"><tr><td><![endif]-->"
+ "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"max-width:600px;table-layout:fixed\"><tr>"
+ "<td class=\"email-content\" style=\"padding:24px;font-family:Arial,sans-serif;line-height:1.6;overflow-wrap:anywhere;word-break:break-word;background-color:"
+ ContrastCheck.color(brand, "backgroundColor") + ";color:" + ContrastCheck.color(brand, "textColor") + "\">" + logos(brand)
+ body + footer(brand, category, unsubscribe) + "</td></tr></table><!--[if mso]></td></tr></table><![endif]--></td></tr></table></body></html>");
}
/*******************************************************************************
** Logo alternatives, with dimensions that never force horizontal overflow.
*******************************************************************************/
private static String logos(QRecord brand)
{
String light = brand.getValueString("logoUrl");
if(light == null || light.isBlank())
{
return ("<p><strong>" + EmailTemplateEngine.escape(brand.getValueString("label")) + "</strong></p>");
}
String dark = brand.getValueString("logoDarkUrl");
return (logo(light, brand.getValueString("logoAlt"), dark == null ? "" : "email-logo-light", "block")
+ (dark == null ? "" : logo(dark, brand.getValueString("logoAlt"), "email-logo-dark", "none")));
}
/*******************************************************************************
** Escaped logo element.
*******************************************************************************/
private static String logo(String url, String alt, String css, String display)
{
return ("<img class=\"" + css + "\" src=\"" + EmailTemplateEngine.escape(VariableSchema.url(url)) + "\" alt=\""
+ EmailTemplateEngine.escape(alt) + "\" width=\"180\" style=\"display:" + display + ";max-width:100%;height:auto\">");
}
/*******************************************************************************
** Category-specific footer, including postal address for marketing.
*******************************************************************************/
public static String footer(QRecord brand, EmailCategory category, String unsubscribe)
{
StringBuilder footer = new StringBuilder("<footer style=\"margin-top:24px;font-size:12px;overflow-wrap:anywhere\">");
footer.append(EmailMarkdown.render(brand.getValueString("footerMarkdown"), brand));
if(category == EmailCategory.MARKETING)
{
String address = brand.getValueString("postalAddress");
if(address == null || address.isBlank())
{
throw (new IllegalArgumentException("Marketing email needs a brand postal address"));
}
footer.append("<p>").append(EmailTemplateEngine.escape(address)).append("</p>");
}
for(var link : EmailBrands.social(brand))
{
footer.append("<a style=\"color:inherit\" href=\"").append(EmailTemplateEngine.escape(link.get("url"))).append("\">")
.append(EmailTemplateEngine.escape(link.get("label"))).append("</a> ");
}
if((category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING) && unsubscribe != null && !unsubscribe.isBlank())
{
footer.append("<p><a style=\"color:inherit\" href=\"").append(EmailTemplateEngine.escape(unsubscribe)).append("\">Unsubscribe</a></p>");
}
return (footer.append("</footer>").toString());
}
/*******************************************************************************
** HTML and compiled MJML retain their shell; append opt-out before body end.
*******************************************************************************/
public static String appendFooter(String html, QRecord brand, EmailCategory category, String unsubscribe)
{
if(category != EmailCategory.NOTIFICATION && category != EmailCategory.MARKETING)
{
return (html);
}
String footer = footer(brand, category, unsubscribe);
java.util.regex.Matcher end = java.util.regex.Pattern.compile("</body\\s*>", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(html);
return (end.find() ? html.substring(0, end.start()) + footer + html.substring(end.start()) : html + footer);
}
}
@@ -0,0 +1,71 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
/*******************************************************************************
** CommonMark with GFM tables, autolinks and a constrained button attribute.
*******************************************************************************/
public final class EmailMarkdown
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailMarkdown()
{
}
/*******************************************************************************
** Raw HTML is escaped and URLs sanitized before adding table buttons.
*******************************************************************************/
public static String render(String markdown, QRecord brand)
{
var extensions = List.of(TablesExtension.create(), AutolinkExtension.create());
String html = HtmlRenderer.builder().escapeHtml(true).sanitizeUrls(true).extensions(extensions).build()
.render(Parser.builder().extensions(extensions).build().parse(markdown == null ? "" : markdown))
.replace("<a ", "<a style=\"color:inherit;text-decoration:underline\" ");
Matcher links = Pattern.compile("<a[^>]* href=\"([^\"]*)\"[^>]*>((?:(?!</?a\\b)[^\\r\\n])*)</a>\\{\\.button}").matcher(html);
StringBuilder result = new StringBuilder();
while(links.find())
{
String button = "<table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\"><tr><td class=\"email-button\" bgcolor=\""
+ ContrastCheck.color(brand, "primaryColor") + "\" style=\"border-radius:4px;padding:12px 20px;background-color:"
+ ContrastCheck.color(brand, "primaryColor") + "\"><a href=\"" + links.group(1) + "\" style=\"display:inline-block;text-decoration:none;color:"
+ ContrastCheck.color(brand, "buttonTextColor") + ";font-weight:bold\">" + links.group(2) + "</a></td></tr></table>";
links.appendReplacement(result, Matcher.quoteReplacement(button));
}
links.appendTail(result);
return (result.toString().replace("<p><table", "<table").replace("</table></p>", "</table>").replace("<table>", "<table style=\"width:100%;table-layout:fixed;border-collapse:collapse\">")
.replace("<td>", "<td style=\"padding:6px;border:1px solid #888;overflow-wrap:anywhere\">")
.replace("<th>", "<th style=\"padding:6px;border:1px solid #888;overflow-wrap:anywhere\">")
.replace("<img ", "<img style=\"max-width:100%;height:auto\" ").replace("<pre>", "<pre style=\"white-space:pre-wrap;overflow-wrap:anywhere\">"));
}
}
@@ -0,0 +1,150 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.template.VariableSchema;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Queue-time link signing and verification from DESIGN 8.2.
*******************************************************************************/
public final class EmailUnsubscribeLinks
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailUnsubscribeLinks()
{
}
/*******************************************************************************
** Bind the message, single mailbox hash and category without exposing PII.
** No key means no link/header; marketing must refuse rather than omit opt-out.
*******************************************************************************/
public static String create(String messageId, String recipient, String category)
{
if(!"NOTIFICATION".equals(category) && !"MARKETING".equals(category))
{
return (null);
}
String key;
try
{
key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
}
catch(Exception e)
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
String base = EmailRuntime.config().getPublicBaseUrl();
if(key == null || key.isBlank() || base == null || base.isBlank())
{
if("MARKETING".equals(category))
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
return (null);
}
String address = recipient(recipient, null, null);
try
{
VariableSchema.url(base);
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(address.getBytes(StandardCharsets.UTF_8)));
String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(VariableSchema.json(Map.of("messageId", messageId, "addressHash", hash, "category", category)).getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signed = "v1." + payload;
String signature = Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)));
return (base.replaceAll("/+$", "") + EmailRuntime.config().getWebhookBasePath() + "/unsubscribe/" + signed + "." + signature);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
}
/*******************************************************************************
** Exactly one To mailbox, canonicalized for signing and category suppression.
*******************************************************************************/
public static String recipient(String to, String cc, String bcc)
{
try
{
InternetAddress[] addresses = InternetAddress.parse(to == null ? "" : to, true);
if(addresses.length == 1 && !addresses[0].isGroup() && addresses[0].getAddress().contains("@")
&& (cc == null || cc.isBlank()) && (bcc == null || bcc.isBlank()))
{
addresses[0].validate();
return (addresses[0].getAddress().toLowerCase(Locale.ROOT));
}
}
catch(jakarta.mail.internet.AddressException e)
{
// Malformed stored envelopes cannot authorize an opt-out.
}
throw (new IllegalArgumentException("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient."));
}
/*******************************************************************************
** Verify the signed format before any lookup or side effect.
** Invalid and non-opt-out categories are indistinguishable to callers.
*******************************************************************************/
public static org.json.JSONObject verify(String token)
{
try
{
if(token == null || token.length() > 4096)
{
return (null);
}
String[] parts = token.split("\\.", -1);
String key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
if(parts.length != 3 || !"v1".equals(parts[0]) || key == null || key.isBlank())
{
return (null);
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
if(!MessageDigest.isEqual(mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)), Base64.getUrlDecoder().decode(parts[2])))
{
return (null);
}
var payload = new org.json.JSONObject(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
String category = payload.getString("category");
return (("MARKETING".equals(category) || "NOTIFICATION".equals(category)) ? payload : null);
}
catch(Exception e)
{
return (null);
}
}
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.parser.ParserDelegator;
import java.io.StringReader;
/*******************************************************************************
** HTML to readable text using the JDK parser (including entity decoding).
*******************************************************************************/
public final class HtmlToText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private HtmlToText()
{
}
/*******************************************************************************
** Keep links, headings, lists and table rows; omit scripts and styles.
*******************************************************************************/
public static String convert(String html)
{
StringBuilder text = new StringBuilder();
try
{
new ParserDelegator().parse(new StringReader(html == null ? "" : html), new HTMLEditorKit.ParserCallback()
{
private int hidden;
private String href;
/*******************************************************************************
** Start readable block boundaries and remember link destinations.
*******************************************************************************/
@Override
public void handleStartTag(HTML.Tag tag, MutableAttributeSet attributes, int position)
{
if(tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT || tag == HTML.Tag.HEAD)
{
hidden++;
}
if(tag == HTML.Tag.A)
{
href = (String) attributes.getAttribute(HTML.Attribute.HREF);
}
if(tag == HTML.Tag.LI)
{
text.append("\n- ");
}
if(tag == HTML.Tag.TD || tag == HTML.Tag.TH)
{
text.append(" ");
}
}
/*******************************************************************************
** Add link destinations and block separators.
*******************************************************************************/
@Override
public void handleEndTag(HTML.Tag tag, int position)
{
if(tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT || tag == HTML.Tag.HEAD)
{
hidden = Math.max(0, hidden - 1);
}
if(tag == HTML.Tag.A && href != null)
{
text.append(" (").append(href).append(')');
href = null;
}
if(tag.isBlock())
{
text.append('\n');
}
}
/*******************************************************************************
** Line breaks remain line breaks.
*******************************************************************************/
@Override
public void handleSimpleTag(HTML.Tag tag, MutableAttributeSet attributes, int position)
{
if(tag == HTML.Tag.BR)
{
text.append('\n');
}
}
/*******************************************************************************
** Parser-decoded text, excluding executable and metadata content.
*******************************************************************************/
@Override
public void handleText(char[] data, int position)
{
if(hidden == 0)
{
text.append(data);
}
}
}, true);
}
catch(java.io.IOException e)
{
throw (new IllegalArgumentException("Could not convert email HTML"));
}
return (text.toString().replaceAll("[ \\t]+", " ").replaceAll(" *\\n *", "\n").replaceAll("\\n{3,}", "\n\n").strip());
}
}
@@ -152,7 +152,7 @@ public final class SystemWrites
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()));
return (call(() -> new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false).withFilter(filter)).getRecords()));
}
@@ -162,7 +162,7 @@ public final class SystemWrites
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
{
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter).withTransaction(transaction)).getRecords()));
return (call(() -> new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false).withFilter(filter).withTransaction(transaction)).getRecords()));
}
@@ -0,0 +1,133 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/*******************************************************************************
** MJML stdin/stdout compiler with a total 20-second deadline and no files.
*******************************************************************************/
public class CliMjmlCompiler implements MjmlCompilerInterface
{
private final String binary;
/*******************************************************************************
** Explicit executable path; no shell interpolation or PATH guessing.
*******************************************************************************/
public CliMjmlCompiler(String binary)
{
this.binary = binary;
}
/*******************************************************************************
** Drain stdout concurrently with stdin, discarding potentially private stderr.
*******************************************************************************/
@Override
public String compile(String source)
{
rejectIncludes(source);
Process process = null;
try(var executor = Executors.newVirtualThreadPerTaskExecutor())
{
process = processBuilder().start();
Process running = process;
var task = executor.submit(() ->
{
var writer = executor.submit(() ->
{
try(var stdin = running.getOutputStream())
{
stdin.write(source.getBytes(StandardCharsets.UTF_8));
}
return (null);
});
byte[] output = running.getInputStream().readNBytes(10_000_001);
if(output.length > 10_000_000)
{
throw (new IllegalArgumentException("MJML output too large"));
}
writer.get();
if(running.waitFor() != 0)
{
throw (new IllegalArgumentException("MJML compilation failed"));
}
return (new String(output, StandardCharsets.UTF_8));
});
try
{
return (task.get(20, TimeUnit.SECONDS));
}
finally
{
running.descendants().forEach(ProcessHandle::destroyForcibly);
running.destroyForcibly();
task.cancel(true);
}
}
catch(Exception e)
{
if(e instanceof InterruptedException)
{
Thread.currentThread().interrupt();
}
throw (new IllegalArgumentException("MJML compilation failed or exceeded 20 seconds"));
}
finally
{
if(process != null)
{
process.destroyForcibly();
}
}
}
/*******************************************************************************
** MJML 4.x/5 supports allowIncludes=false. Refusing the source tag as well
** removes include resolution and its dependence on the inherited directory.
*******************************************************************************/
public static void rejectIncludes(String source)
{
if(source != null && java.util.regex.Pattern.compile("(?i)<\\s*mj-include\\b").matcher(source).find())
{
throw (new IllegalArgumentException("MJML includes are not allowed"));
}
}
/*******************************************************************************
** Package seam for checking child isolation without executing a compiler.
*******************************************************************************/
ProcessBuilder processBuilder()
{
ProcessBuilder builder = new ProcessBuilder(binary, "-i", "-s", "--config.allowIncludes=false");
String path = builder.environment().get("PATH");
builder.environment().clear();
if(path != null)
{
builder.environment().put("PATH", path);
}
return (builder.redirectError(ProcessBuilder.Redirect.DISCARD));
}
}
@@ -0,0 +1,77 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Built-ins are created on first use, transactionally and without JVM state.
*******************************************************************************/
public final class EmailBuiltIns
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBuiltIns()
{
}
/*******************************************************************************
** Seed the secure-link pattern. The unique template name arbitrates replicas.
*******************************************************************************/
public static void ensure() throws QException
{
if(!SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "email_secure_message")).isEmpty())
{
return;
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
{
QRecord template = SystemWrites.insert(EmailTables.TEMPLATE, List.of(new QRecord().withValue("name", "email_secure_message")
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false)), tx).getRecords().getFirst();
if(template.getValue("id") == null || template.getErrors() != null && !template.getErrors().isEmpty())
{
tx.rollback();
return;
}
QRecord version = SystemWrites.insert(EmailTables.VERSION, List.of(new QRecord().withValue("templateId", template.getValue("id"))
.withValue("versionNumber", 1).withValue("status", "ACTIVE").withValue("locale", "en").withValue("subject", "New secure message")
.withValue("bodySource", "You have a new secure message from {{brand.label}}. Sign in to read it.\n\n[Read secure message]({{link}}){.button}")
.withValue("variablesSchema", "[{\"name\":\"link\",\"type\":\"URL\",\"required\":true}]")
.withValue("sampleVariables", "{\"link\":\"https://example.invalid/sign-in\"}")), tx).getRecords().getFirst();
if(version.getValue("id") == null || version.getErrors() != null && !version.getErrors().isEmpty())
{
throw (new QException("Could not install secure-message template"));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id")).withValue("activeVersionId", version.getValue("id")), tx);
tx.commit();
}
}
}
@@ -0,0 +1,130 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.render.ContrastCheck;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
/*******************************************************************************
** Inline preview fallback: Next strips iframes and style blocks. Samples are
** sanitized server-side too; no live record references are read for previews.
*******************************************************************************/
public class EmailPreviewWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Fetch the version and parent as the viewer, then render sample data.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
try
{
int id = Integer.parseInt(input.getQueryParams().get("id"));
List<QRecord> versions = new QueryAction().execute(new QueryInput(EmailTables.VERSION).withShouldFetchHeavyFields(true)
.withFilter(EmailStore.equal("id", id))).getRecords();
if(versions.isEmpty())
{
return (output("<p>Version unavailable.</p>"));
}
QRecord version = versions.getFirst();
List<QRecord> templates = new QueryAction().execute(new QueryInput(EmailTables.TEMPLATE)
.withFilter(EmailStore.equal("id", version.getValueInteger("templateId")))).getRecords();
if(templates.isEmpty())
{
return (output("<p>Template unavailable.</p>"));
}
QRecord template = templates.getFirst();
if("MJML".equals(template.getValueString("kind")) && version.getValueString("compiledHtml") == null)
{
if(!com.kingsrook.qbits.email.permissions.EmailPermissionChecker.has(com.kingsrook.qbits.email.permissions.EmailPermissions.MANAGE))
{
return (output("<p>Not compiled yet. Activate the version or use Preview as a manager.</p>"));
}
EmailVersions.validate(template, version);
}
EmailTemplateRenderer renderer = new EmailTemplateRenderer(template, version,
new EmailRequest().withVariables(VariableSchema.values(version.getValueString("sampleVariables"))));
QRecord rendered = renderer.render("preview", "https://example.invalid/unsubscribe/preview", false);
QRecord brand = EmailBrands.resolve(null, template);
String html = "<div style=\"min-width:0;overflow-wrap:anywhere\"><h3>" + EmailTemplateEngine.escape(rendered.getValueString("subject")) + "</h3>"
+ "<p>Sample preview. Inline rendering; mail clients may display it differently.</p>"
+ sample(rendered.getValueString("htmlBody"), brand, false, 390) + sample(rendered.getValueString("htmlBody"), brand, true, 600)
+ "<h4>Plain text</h4><pre style=\"white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%\">"
+ EmailTemplateEngine.escape(rendered.getValueString("textBody")) + "</pre></div>";
return (output(html + "<p>" + EmailTemplateEngine.escape(String.join("; ", renderer.warnings())) + "</p>"));
}
catch(IllegalArgumentException e)
{
return (output("<p>Preview unavailable: " + EmailTemplateEngine.escape(e.getMessage()) + "</p>"));
}
}
/*******************************************************************************
** Safe inline sample with bounded width and no executable source or CSS.
*******************************************************************************/
private static String sample(String html, QRecord brand, boolean dark, int width)
{
var document = Jsoup.parse(html);
document.select("head,script,style,iframe,form").remove();
document.select(".email-logo-" + (dark ? "light" : "dark")).remove();
String clean = Jsoup.clean(document.body().html(), Safelist.relaxed().addAttributes("td", "class"));
document = Jsoup.parseBodyFragment(clean);
document.select("table").forEach(e -> e.attr("style", "width:100%;table-layout:fixed;border-collapse:collapse"));
document.select("td,th").forEach(e -> e.attr("style", "overflow-wrap:anywhere;padding:6px"));
document.select("img").forEach(e -> e.attr("style", "max-width:100%;height:auto"));
document.select("pre").forEach(e -> e.attr("style", "white-space:pre-wrap;overflow-wrap:anywhere"));
document.select("a").forEach(e -> e.attr("style", "color:inherit;overflow-wrap:anywhere"));
document.select(".email-content").forEach(e -> e.attr("style", e.attr("style") + ";background-color:"
+ ContrastCheck.color(brand, dark ? "darkBackgroundColor" : "backgroundColor") + ";color:"
+ ContrastCheck.color(brand, dark ? "darkTextColor" : "textColor")));
document.select(".email-button").forEach(e -> e.attr("style", "padding:12px 20px;background-color:" + ContrastCheck.color(brand, dark ? "darkPrimaryColor" : "primaryColor")
+ ";color:" + ContrastCheck.color(brand, dark ? "darkButtonTextColor" : "buttonTextColor") + "!important"));
document.select(".email-button a").forEach(e -> e.attr("style", "color:" + ContrastCheck.color(brand, dark ? "darkButtonTextColor" : "buttonTextColor") + "!important;display:block;overflow-wrap:anywhere"));
document.select("[class]").forEach(e -> e.removeAttr("class"));
return ("<h4>" + (dark ? "Dark" : "Light") + " · " + width + "px maximum</h4><div style=\"box-sizing:border-box;width:100%;max-width:"
+ width + "px;padding:12px;overflow-wrap:anywhere;background-color:" + ContrastCheck.color(brand, dark ? "darkBackgroundColor" : "backgroundColor")
+ ";color:" + ContrastCheck.color(brand, dark ? "darkTextColor" : "textColor") + "\">" + document.body().html() + "</div>");
}
/*******************************************************************************
** Standard widget response.
*******************************************************************************/
private static RenderWidgetOutput output(String html)
{
return (new RenderWidgetOutput(new RawHTML(null, html)));
}
}
@@ -0,0 +1,172 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Lifecycle and template edit permissions apply equally to forms and APIs.
*******************************************************************************/
public class EmailTemplateCustomizers implements TableCustomizerInterface
{
/*******************************************************************************
** Merge sparse updates and protect stored lifecycle state, not caller claims.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> rows, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
if(SystemWrites.active())
{
return (rows);
}
for(QRecord row : rows)
{
QRecord old = oldRecords.orElse(List.of()).stream().filter(v -> Objects.equals(v.getValue("id"), row.getValue("id"))).findFirst().orElse(new QRecord());
QRecord merged = new QRecord();
merged.getValues().putAll(old.getValues());
merged.getValues().putAll(row.getValues());
try
{
String table = input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput insert ? insert.getTableName()
: ((com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput) input).getTableName();
if(EmailTables.BRAND.equals(table))
{
EmailBrands.validate(merged);
}
else if(EmailTables.TEMPLATE.equals(table))
{
EmailVersions.requireEdit(old.getValue("id") == null ? merged : old, false);
protectedFields(row, old, List.of("phiApproved", "approvedVersionId", "phiApprovedBy", "phiApprovedDate", "activeVersionId"));
if(Boolean.TRUE.equals(old.getValueBoolean("phiApproved")))
{
protectedFields(row, old, List.of("kind", "category", "brandId", "name"));
}
}
else
{
QRecord parent = EmailStore.get(EmailTables.TEMPLATE, merged.getValueInteger("templateId"), null);
if(parent == null)
{
throw (new IllegalArgumentException("Template is required"));
}
EmailVersions.requireEdit(parent, false);
if(old.getValue("id") != null)
{
protectedFields(row, old, List.of("templateId", "versionNumber", "status", "compiledHtml", "activatedDate"));
immutable(old, parent);
}
else
{
if(merged.getValueString("status") != null && !"DRAFT".equals(merged.getValueString("status")))
{
throw (new IllegalArgumentException("New versions must be DRAFT"));
}
row.setValue("status", "DRAFT");
row.setValue("compiledHtml", null);
}
new VariableSchema(merged.getValueString("variablesSchema"));
}
}
catch(IllegalArgumentException e)
{
row.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (rows);
}
/*******************************************************************************
** Lifecycle-owned fields cannot be changed through ordinary CRUD.
*******************************************************************************/
private static void protectedFields(QRecord row, QRecord old, List<String> fields)
{
for(String field : fields)
{
Object value = row.getValue(field);
if(row.getValues().containsKey(field) && !Objects.equals(value, old.getValue(field)) && !(old.getValue("id") == null && Boolean.FALSE.equals(value)))
{
throw (new IllegalArgumentException("Use the version actions to change " + field));
}
}
}
/*******************************************************************************
** Approving a draft freezes its content, preventing approval substitution.
*******************************************************************************/
private static void immutable(QRecord version, QRecord template)
{
if(!"DRAFT".equals(version.getValueString("status")) || Objects.equals(version.getValue("id"), template.getValue("approvedVersionId")))
{
throw (new IllegalArgumentException("ACTIVE, RETIRED and PHI-approved versions cannot be edited; use New Version"));
}
}
/*******************************************************************************
** Preserve active/history rows and the same edit grants on deletion.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> rows, boolean preview) throws QException
{
if(SystemWrites.active())
{
return (rows);
}
for(QRecord row : rows)
{
try
{
if(EmailTables.VERSION.equals(input.getTableName()))
{
QRecord parent = EmailStore.get(EmailTables.TEMPLATE, row.getValueInteger("templateId"), input.getTransaction());
EmailVersions.requireEdit(parent, false);
immutable(row, parent);
}
else if(EmailTables.TEMPLATE.equals(input.getTableName()))
{
EmailVersions.requireEdit(row, false);
if(!SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", row.getValueInteger("id")), input.getTransaction()).isEmpty())
{
throw (new IllegalArgumentException("Templates with versions cannot be deleted"));
}
}
}
catch(IllegalArgumentException e)
{
row.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (rows);
}
}
@@ -0,0 +1,315 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.outbox.EmailCategory;
/*******************************************************************************
** A template another qbit ships with its own code: its name, content and
** variable contract. Hand it to {@link EmailTemplates#ensure} at startup or on
** first use; it is created once as an ACTIVE version 1 and never overwritten.
*******************************************************************************/
public final class EmailTemplateDefinition
{
private String name;
private String label;
private EmailCategory category;
private String kind = "MARKDOWN";
private String subject;
private String body;
private String textBody;
private String variablesSchema;
private final List<Map<String, Object>> variables = new ArrayList<>();
private String sampleVariables;
private String ownerQbit;
private String editPermissionName;
/*******************************************************************************
** Unique snake_case template name, as used by EmailRequest.withTemplateName.
*******************************************************************************/
public EmailTemplateDefinition withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Human label shown in the template table.
*******************************************************************************/
public EmailTemplateDefinition withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Template category; it decides opt-out and PHI rules.
*******************************************************************************/
public EmailTemplateDefinition withCategory(EmailCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** MARKDOWN (the default), MJML or HTML.
*******************************************************************************/
public EmailTemplateDefinition withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Subject source, which may use variables.
*******************************************************************************/
public EmailTemplateDefinition withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Body source in the template's kind.
*******************************************************************************/
public EmailTemplateDefinition withBody(String body)
{
this.body = body;
return (this);
}
/*******************************************************************************
** Optional plain-text source; omitted, it is derived from the body.
*******************************************************************************/
public EmailTemplateDefinition withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** The variables schema as the JSON list the template table stores. Use this or
** withVariable, not both.
*******************************************************************************/
public EmailTemplateDefinition withVariablesSchema(String variablesSchema)
{
this.variablesSchema = variablesSchema;
return (this);
}
/*******************************************************************************
** Declare one variable (type: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL,
** LIST or RECORD; classification: PUBLIC, CONFIDENTIAL, PII, PHI or null).
*******************************************************************************/
public EmailTemplateDefinition withVariable(String name, String label, String type, boolean required, String classification)
{
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", name);
entry.put("label", label);
entry.put("type", type);
entry.put("required", required);
entry.put("classification", classification);
variables.add(entry);
return (this);
}
/*******************************************************************************
** Synthetic sample values as a JSON object, used to validate and preview.
*******************************************************************************/
public EmailTemplateDefinition withSampleVariables(String sampleVariables)
{
this.sampleVariables = sampleVariables;
return (this);
}
/*******************************************************************************
** The qbit that owns this template.
*******************************************************************************/
public EmailTemplateDefinition withOwnerQbit(String ownerQbit)
{
this.ownerQbit = ownerQbit;
return (this);
}
/*******************************************************************************
** Optional permission, besides the email permission, needed to edit the template.
*******************************************************************************/
public EmailTemplateDefinition withEditPermissionName(String editPermissionName)
{
this.editPermissionName = editPermissionName;
return (this);
}
/*******************************************************************************
** The template name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** The template label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** The template category.
*******************************************************************************/
public EmailCategory getCategory()
{
return (category);
}
/*******************************************************************************
** The template kind.
*******************************************************************************/
public String getKind()
{
return (kind);
}
/*******************************************************************************
** The subject source.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** The body source.
*******************************************************************************/
public String getBody()
{
return (body);
}
/*******************************************************************************
** The optional plain-text source.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** The schema JSON: the explicit text, or the typed variables as a JSON list.
*******************************************************************************/
public String getVariablesSchema()
{
if(variablesSchema != null && !variablesSchema.isBlank())
{
return (variablesSchema);
}
return (VariableSchema.json(variables));
}
/*******************************************************************************
** The sample values JSON, defaulting to an empty object.
*******************************************************************************/
public String getSampleVariables()
{
return (sampleVariables == null || sampleVariables.isBlank() ? "{}" : sampleVariables);
}
/*******************************************************************************
** The owning qbit.
*******************************************************************************/
public String getOwnerQbit()
{
return (ownerQbit);
}
/*******************************************************************************
** The optional edit permission.
*******************************************************************************/
public String getEditPermissionName()
{
return (editPermissionName);
}
/*******************************************************************************
** Plain-language reasons the definition is incomplete or malformed, before any
** rendering check. Empty when it passes.
*******************************************************************************/
public List<String> problems()
{
List<String> reasons = new ArrayList<>();
if(name == null || !name.matches("[a-z][a-z0-9_]{0,99}"))
{
reasons.add("Name must be snake_case: lowercase letters, digits and underscores, starting with a letter");
}
if(label == null || label.isBlank())
{
reasons.add("Label is required");
}
if(category == null)
{
reasons.add("Category is required");
}
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML").contains(kind))
{
reasons.add("Kind must be MARKDOWN, MJML or HTML");
}
if(subject == null || subject.isBlank())
{
reasons.add("Subject is required");
}
if(body == null || body.isBlank())
{
reasons.add("Body is required");
}
if(variablesSchema != null && !variablesSchema.isBlank() && !variables.isEmpty())
{
reasons.add("Declare variables with withVariablesSchema or withVariable, not both");
}
else
{
try
{
new VariableSchema(getVariablesSchema());
}
catch(IllegalArgumentException e)
{
reasons.add(e.getMessage());
}
}
try
{
VariableSchema.values(sampleVariables);
}
catch(IllegalArgumentException e)
{
reasons.add(e.getMessage());
}
return (reasons);
}
}
@@ -0,0 +1,418 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/*******************************************************************************
** Small parsed template language: values, fallback, if and each, without code.
*******************************************************************************/
public class EmailTemplateEngine
{
private static final Pattern URL_ATTRIBUTE = Pattern.compile("(?i)(?:^|[\\s<])(href|src|action|background)\\s*=\\s*[\"']$");
private static final Pattern RENDERED_URL = Pattern.compile("(?is)\\b(href|src|action|background)(\\s*=\\s*)([\"'])(.*?)\\3");
/*******************************************************************************
** One budget shared by all parts and nested blocks of a render.
*******************************************************************************/
public static class Budget
{
private int iterations;
private int bytes;
/*******************************************************************************
** Count work before entering a loop body.
*******************************************************************************/
private void iterate()
{
if(++iterations > 10_000)
{
throw (new IllegalArgumentException("Template output is too large"));
}
}
/*******************************************************************************
** Bound UTF-8 output before appending it.
*******************************************************************************/
private void append(StringBuilder out, String value)
{
if(value.length() > 1_048_576 || (bytes += value.getBytes(StandardCharsets.UTF_8).length) > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
out.append(value);
}
}
/*******************************************************************************
** Rendering context. Markdown uses entities for all punctuation so values
** cannot inject Markdown links, images or block syntax.
*******************************************************************************/
public enum Context
{
HTML, TEXT, MARKDOWN
}
/*******************************************************************************
** A parsed literal, expression or block.
*******************************************************************************/
private record Node(String kind, String value, String fallback, int line, List<Node> children, String attribute)
{
}
/*******************************************************************************
** Validated immutable parse result.
*******************************************************************************/
public static class Parsed
{
private final List<Node> nodes;
private final Set<String> references;
/*******************************************************************************
** Store parsed nodes.
*******************************************************************************/
private Parsed(List<Node> nodes, Set<String> references)
{
this.nodes = nodes;
this.references = Set.copyOf(references);
}
/*******************************************************************************
** Render the already validated syntax tree.
*******************************************************************************/
public String render(Map<String, ?> values, Context context)
{
return (render(values, context, new Budget(), new ArrayList<>()));
}
/*******************************************************************************
** Bindings referenced by values and block conditions, including nested blocks.
*******************************************************************************/
public Set<String> references()
{
return (references);
}
/*******************************************************************************
** Render using the message-wide budget and safe author-facing warnings.
*******************************************************************************/
public String render(Map<String, ?> values, Context context, Budget budget, List<String> warnings)
{
StringBuilder out = new StringBuilder();
renderNodes(nodes, values, null, -1, context, budget, warnings, out);
return (context == Context.HTML ? validateRenderedUrls(out.toString(), warnings) : out.toString());
}
}
/*******************************************************************************
** Parse once, checking balanced blocks and every variable, with line numbers.
*******************************************************************************/
public Parsed parse(String source, VariableSchema schema)
{
source = source == null ? "" : source;
List<Node> root = new ArrayList<>();
Deque<Node> stack = new ArrayDeque<>();
Set<String> references = new LinkedHashSet<>();
int position = 0;
int line = 1;
while(position < source.length())
{
List<Node> current = stack.isEmpty() ? root : stack.peek().children();
int start = source.indexOf("{{", position);
if(start < 0)
{
current.add(new Node("literal", source.substring(position), null, 0, List.of(), null));
break;
}
current.add(new Node("literal", source.substring(position, start), null, 0, List.of(), null));
for(int i = position; i < start; i++)
{
if(source.charAt(i) == '\n')
{
line++;
}
}
int end = source.indexOf("}}", start + 2);
if(end < 0)
{
throw (error(line, "Unclosed expression"));
}
String expression = source.substring(start + 2, end).trim();
if(expression.startsWith("/"))
{
if(stack.isEmpty() || !stack.peek().kind().equals(expression.substring(1)))
{
throw (error(line, "Block nesting error"));
}
stack.pop();
}
else
{
String kind = expression.startsWith("#if ") ? "if" : expression.startsWith("#each ") ? "each" : "value";
String binding = kind.equals("value") ? expression : expression.substring(kind.length() + 2).trim();
String[] parts = binding.split("\\|", 2);
String name = parts[0].trim();
boolean inEach = stack.stream().anyMatch(n -> n.kind().equals("each"));
boolean local = name.equals("this") || name.startsWith("this.") || name.equals("@index");
if(!name.matches("(?:[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*|@index)")
|| (local ? !inEach : !schema.knows(name)) || !kind.equals("value") && parts.length > 1)
{
throw (error(line, "Unknown or unsupported variable " + name));
}
if(!local)
{
references.add(name);
}
var attribute = URL_ATTRIBUTE.matcher(source.substring(position, start));
Node node = new Node(kind, name, parts.length == 2 ? parts[1] : "", line, new ArrayList<>(), attribute.find() ? attribute.group(1).toLowerCase(java.util.Locale.ROOT) : null);
current.add(node);
if(!kind.equals("value"))
{
if(stack.size() >= 8)
{
throw (error(line, "Blocks exceed 8 levels"));
}
stack.push(node);
}
}
for(int i = start; i < end + 2; i++)
{
if(source.charAt(i) == '\n')
{
line++;
}
}
position = end + 2;
}
if(!stack.isEmpty())
{
throw (error(stack.peek().line(), "Unclosed " + stack.peek().kind() + " block"));
}
return (new Parsed(root, references));
}
/*******************************************************************************
** Render syntax nodes; loop scope is restored on every recursive return.
*******************************************************************************/
private static void renderNodes(List<Node> nodes, Map<String, ?> values, Object item, int index, Context context, Budget budget, List<String> warnings, StringBuilder out)
{
for(Node node : nodes)
{
Object value = node.value().equals("this") ? item : node.value().startsWith("this.") ? lookup(item, node.value().substring(5))
: node.value().equals("@index") ? index : lookup(values, node.value());
switch(node.kind())
{
case "literal" -> budget.append(out, node.value());
case "value" ->
{
String text = value == null || value.toString().isEmpty() ? node.fallback() : value.toString();
if(context == Context.HTML && node.attribute() != null && !text.isEmpty())
{
try
{
VariableSchema.url(text, "href".equals(node.attribute()));
}
catch(IllegalArgumentException e)
{
text = "";
String warning = "Unsafe URL omitted for " + node.value();
if(!warnings.contains(warning))
{
warnings.add(warning);
}
}
}
if(text.length() > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
budget.append(out, encode(text, context));
}
case "if" ->
{
if(truthy(value))
{
renderNodes(node.children(), values, item, index, context, budget, warnings, out);
}
}
case "each" ->
{
if(value instanceof List<?> list)
{
for(int i = 0; i < list.size(); i++)
{
budget.iterate();
renderNodes(node.children(), values, list.get(i), i, context, budget, warnings, out);
}
}
}
default -> throw (new IllegalStateException("Invalid template node"));
}
}
}
/*******************************************************************************
** Expressions in attributes require quotes, including after literal prefixes.
*******************************************************************************/
public static void validateHtml(String source)
{
boolean tag = false;
char quote = 0;
for(int i = 0; source != null && i < source.length(); i++)
{
char c = source.charAt(i);
if(tag && quote == 0 && source.startsWith("{{", i))
{
throw (new IllegalArgumentException("Template expressions in attributes must be quoted"));
}
if(quote != 0)
{
if(c == quote)
{
quote = 0;
}
}
else if(c == '<')
{
tag = true;
}
else if(c == '>')
{
tag = false;
}
else if(tag && (c == '\"' || c == '\''))
{
quote = c;
}
}
}
/*******************************************************************************
** Recheck complete attributes after block expansion, so whitespace, split
** values and conditional expressions cannot hide the URL context.
*******************************************************************************/
private static String validateRenderedUrls(String html, List<String> warnings)
{
var matcher = RENDERED_URL.matcher(html);
StringBuilder result = new StringBuilder();
while(matcher.find())
{
String value = org.jsoup.parser.Parser.unescapeEntities(matcher.group(4), true);
try
{
if(!value.isEmpty())
{
VariableSchema.url(value, "href".equalsIgnoreCase(matcher.group(1)));
}
matcher.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(matcher.group()));
}
catch(IllegalArgumentException e)
{
String warning = "Unsafe " + matcher.group(1).toLowerCase(java.util.Locale.ROOT) + " URL omitted";
if(!warnings.contains(warning))
{
warnings.add(warning);
}
matcher.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(matcher.group(1) + matcher.group(2) + matcher.group(3) + matcher.group(3)));
}
}
matcher.appendTail(result);
return (result.toString());
}
/*******************************************************************************
** Dotted paths traverse maps only; no reflection, methods or arbitrary code.
*******************************************************************************/
public static Object lookup(Object values, String path)
{
if(values instanceof Map<?, ?> map)
{
if(map.containsKey(path))
{
return (map.get(path));
}
int dot = path.indexOf('.');
return (dot < 0 ? map.get(path) : lookup(map.get(path.substring(0, dot)), path.substring(dot + 1)));
}
return (null);
}
/*******************************************************************************
** Explicit language truthiness.
*******************************************************************************/
public static boolean truthy(Object value)
{
if(value == null || Boolean.FALSE.equals(value) || value instanceof CharSequence s && s.isEmpty()
|| value instanceof Collection<?> c && c.isEmpty() || value instanceof Map<?, ?> m && m.isEmpty())
{
return (false);
}
return (!(value instanceof Number n) || new BigDecimal(n.toString()).compareTo(BigDecimal.ZERO) != 0);
}
/*******************************************************************************
** Escape values exactly once for the consuming grammar.
*******************************************************************************/
public static String encode(String value, Context context)
{
if(context == Context.TEXT)
{
return (value);
}
if(context == Context.MARKDOWN)
{
StringBuilder encoded = new StringBuilder();
value.codePoints().forEach(c -> encoded.append(Character.isLetterOrDigit(c) || c == ' ' ? new String(Character.toChars(c)) : "&#" + c + ";"));
return (encoded.toString());
}
return (escape(value));
}
/*******************************************************************************
** Attribute and text escaping, including both quote types.
*******************************************************************************/
public static String escape(Object value)
{
return (value == null ? "" : value.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Stable author-facing parse diagnostic.
*******************************************************************************/
private static IllegalArgumentException error(int line, String message)
{
return (new IllegalArgumentException("Line " + line + ": " + message));
}
}
@@ -0,0 +1,453 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.phi.EmailBodyEncryption;
import com.kingsrook.qbits.email.phi.EmailClassifications;
import com.kingsrook.qbits.email.phi.EmailClassifications.Level;
import com.kingsrook.qbits.email.phi.EmailValueTokens;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.render.EmailLayout;
import com.kingsrook.qbits.email.render.EmailMarkdown;
import com.kingsrook.qbits.email.render.HtmlToText;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Queue-time template resolution and rendering with sender-visible record data.
*******************************************************************************/
public final class EmailTemplateRenderer
{
private final QRecord template;
private final QRecord version;
private final QRecord brand;
private final VariableSchema schema;
private final Map<String, Serializable> values;
private final Map<String, EmailRequest.RecordVariable> records;
private final EmailCategory category;
private final boolean approved;
private final List<String> warnings = new ArrayList<>();
/*******************************************************************************
** Resolve a template's active locale, falling back only to English.
*******************************************************************************/
public static EmailTemplateRenderer prepare(EmailRequest request) throws QException
{
if("email_secure_message".equals(request.getTemplateName()))
{
EmailBuiltIns.ensure();
}
List<QRecord> templates = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", request.getTemplateName()));
if(templates.size() != 1)
{
throw (new IllegalArgumentException("Unknown email template"));
}
QRecord template = templates.getFirst();
List<QRecord> versions = SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", template.getValueInteger("id"))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
QRecord version = versions.stream().filter(v -> Objects.equals(request.getLocale(), v.getValueString("locale"))).findFirst()
.orElseGet(() -> versions.stream().filter(v -> "en".equals(v.getValueString("locale"))).findFirst().orElse(null));
if(version == null)
{
throw (new IllegalArgumentException("Template has no active version for this locale or en"));
}
return (new EmailTemplateRenderer(template, version, request));
}
/*******************************************************************************
** Validate schema, provenance and all template syntax before any outbox write.
*******************************************************************************/
public EmailTemplateRenderer(QRecord template, QRecord version, EmailRequest request) throws QException
{
this.template = template;
this.version = version;
approved = Boolean.TRUE.equals(template.getValueBoolean("phiApproved")) && version.getValue("id") != null
&& Objects.equals(template.getValue("approvedVersionId"), version.getValue("id"));
brand = EmailBrands.resolve(approved ? null : request.getBrandName(), template);
if(approved)
{
for(String field : List.of("logoUrl", "logoDarkUrl"))
{
if(brand.getValueString(field) != null && !EmailBrands.allowedImage(brand.getValueString(field), brand))
{
brand.setValue(field, null);
warnings.add("Brand logo omitted: image host is not allowed");
}
}
}
EmailBrands.validate(brand);
schema = new VariableSchema(version.getValueString("variablesSchema"));
records = new LinkedHashMap<>(request.getRecordVariables());
java.util.Set<String> references = new java.util.LinkedHashSet<>();
for(String field : List.of("subject", "preheader", "bodySource", "textSource", "compiledHtml"))
{
references.addAll(new EmailTemplateEngine().parse(version.getValueString(field), schema).references());
}
if(!"MARKDOWN".equals(template.getValueString("kind")))
{
EmailTemplateEngine.validateHtml(version.getValueString("bodySource"));
EmailTemplateEngine.validateHtml(version.getValueString("compiledHtml"));
}
Map<String, Serializable> supplied = new LinkedHashMap<>(request.getVariables());
for(var binding : records.entrySet())
{
supplied.keySet().removeIf(key -> key.startsWith(binding.getKey() + "."));
QTableMetaData table = QContext.getQInstance().getTable(binding.getValue().table());
if(table == null)
{
throw (new IllegalArgumentException("Record variable table is unavailable"));
}
java.util.Set<String> fields = references.stream().filter(path -> path.startsWith(binding.getKey() + "."))
.map(path -> path.substring(binding.getKey().length() + 1).split("\\.")[0]).collect(java.util.stream.Collectors.toSet());
if(fields.isEmpty())
{
supplied.put(binding.getKey(), new LinkedHashMap<>());
continue;
}
// Never use SystemWrites here: masking and record locks belong to the sender.
List<QRecord> rows = new QueryAction().execute(new QueryInput(table.getName()).withShouldFetchHeavyFields(true).withFieldNamesToInclude(fields)
.withFilter(EmailStore.equal(table.getPrimaryKeyField(), binding.getValue().id()).withLimit(1))).getRecords();
if(rows.isEmpty())
{
throw (new IllegalArgumentException("Record variable is unavailable to the sender"));
}
Map<String, Serializable> visible = new LinkedHashMap<>();
fields.forEach(field -> visible.put(field, rows.getFirst().getValue(field)));
for(var field : table.getFields().values())
{
var lock = field.getFieldSecurityLock();
if(lock != null && fields.contains(field.getName()))
{
var session = QContext.getQSession();
List<Serializable> keys = session == null ? List.of() : session.getSecurityKeyValues(lock.getSecurityKeyType());
boolean override = keys != null && lock.getOverrideValues() != null && keys.stream().anyMatch(lock.getOverrideValues()::contains);
boolean allowed = lock.getDefaultBehavior() == com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock.Behavior.ALLOW;
if(override ? allowed : !allowed)
{
visible.put(field.getName(), "************");
}
}
}
supplied.put(binding.getKey(), new LinkedHashMap<>(visible));
}
for(String name : supplied.keySet())
{
if(!schema.knows(name) || name.startsWith("brand.") || name.equals("message.id") || name.equals("now") || name.equals("unsubscribe_url"))
{
throw (new IllegalArgumentException("Unknown or reserved input variable " + name));
}
}
values = schema.validate(supplied);
category = EmailCategory.valueOf(Objects.requireNonNullElse(template.getValueString("category"), "TRANSACTIONAL"));
if(request.getCategory() != null && request.getCategory() != category)
{
throw (new IllegalArgumentException("Template category cannot be overridden"));
}
if(approved && category == EmailCategory.MARKETING)
{
throw (new IllegalArgumentException("PHI-approved templates cannot send MARKETING email"));
}
for(VariableSchema.Entry entry : schema.entries().values())
{
requireApproval(entry.name(), classification(entry.name()));
}
inspect(values, "");
for(String field : List.of("subject", "preheader", "bodySource", "textSource"))
{
new EmailTemplateEngine().parse(version.getValueString(field), schema);
}
if(version.getValueString("subject") == null || version.getValueString("bodySource") == null)
{
throw (new IllegalArgumentException("A template subject and body are required"));
}
}
/*******************************************************************************
** Check actual nested fields as well as declared but absent bindings.
*******************************************************************************/
private void inspect(Object object, String path)
{
if(!path.isEmpty())
{
requireApproval(path, classification(path));
}
if(object instanceof Map<?, ?> map)
{
map.forEach((key, value) -> inspect(value, path.isEmpty() ? key.toString() : path + "." + key));
}
else if(object instanceof List<?> list)
{
list.forEach(value -> inspect(value, path));
}
}
/*******************************************************************************
** A classification failure names only the binding, never its value.
*******************************************************************************/
private void requireApproval(String name, Level level)
{
if(EmailClassifications.sensitive(level) && !approved)
{
throw (new IllegalArgumentException("The variable " + name + " is " + level + "; this template is not approved for PHI."));
}
}
/*******************************************************************************
** Resolve record provenance before schema declarations; inherit parent levels.
*******************************************************************************/
private Level classification(String path)
{
VariableSchema.Entry declaration = schema.entries().get(path);
if(declaration == null)
{
declaration = schema.entries().values().stream().filter(e -> path.startsWith(e.name() + "."))
.max(java.util.Comparator.comparingInt(e -> e.name().length())).orElse(null);
}
String declared = declaration == null ? null : declaration.classification();
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + "."))
{
String field = path.substring(record.getKey().length() + 1).split("\\.")[0];
return (EmailClassifications.ofField(QContext.getQInstance().getTable(record.getValue().table()), field, declared, EmailRuntime.config()));
}
}
QTableMetaData table = declaration == null || declaration.table() == null ? null : QContext.getQInstance().getTable(declaration.table());
if(declaration != null && declaration.table() != null && (table == null || table.getField(declaration.field()) == null))
{
throw (new IllegalArgumentException("Schema field binding is unavailable"));
}
return (EmailClassifications.ofField(table, declaration == null ? null : declaration.field(), declared, EmailRuntime.config()));
}
/*******************************************************************************
** Validate the actual provider at queue and delivery time.
*******************************************************************************/
public void validateProvider(QRecord provider)
{
if(approved)
{
checkProvider(provider);
}
}
/*******************************************************************************
** Recheck mutable provider attestations before encrypted content leaves storage.
*******************************************************************************/
public static void checkProvider(QRecord provider)
{
if(!Boolean.TRUE.equals(provider.getValueBoolean("phiAllowed")) || Boolean.TRUE.equals(provider.getValueBoolean("trackOpens"))
|| Boolean.TRUE.equals(provider.getValueBoolean("trackClicks")))
{
throw (new IllegalArgumentException("PHI email needs a phi_allowed provider with open and click tracking disabled"));
}
}
/*******************************************************************************
** Render with the real message identity; no compiled source is rebuilt at send.
*******************************************************************************/
public QRecord render(String messageId, String unsubscribe, boolean encrypt)
{
Map<String, Serializable> context = new LinkedHashMap<>(values);
for(String field : List.of("label", "name", "siteUrl", "logoUrl", "logoDarkUrl", "logoAlt", "postalAddress", "primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "darkPrimaryColor", "darkButtonTextColor", "footerMarkdown"))
{
context.put("brand." + field.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT), brand.getValueString(field));
}
context.put("message.id", messageId);
context.put("now", EmailRuntime.clock().instant().toString());
context.put("unsubscribe_url", category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING ? unsubscribe : "");
EmailTemplateEngine engine = new EmailTemplateEngine();
EmailTemplateEngine.Budget budget = new EmailTemplateEngine.Budget();
String subject = engine.parse(version.getValueString("subject"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
if(subject.contains("\r") || subject.contains("\n"))
{
throw (new IllegalArgumentException("Subject must be one line"));
}
String kind = template.getValueString("kind");
String source = "MJML".equals(kind) ? version.getValueString("compiledHtml") : version.getValueString("bodySource");
if(source == null)
{
throw (new IllegalArgumentException("MJML version must be compiled before sending"));
}
String body = engine.parse(source, schema).render(context, "MARKDOWN".equals(kind) ? EmailTemplateEngine.Context.MARKDOWN : EmailTemplateEngine.Context.HTML, budget, warnings);
String preheader = engine.parse(version.getValueString("preheader"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
String html = "MARKDOWN".equals(kind) ? EmailLayout.render(EmailMarkdown.render(body, brand), preheader, brand, category, unsubscribe)
: EmailLayout.appendFooter(body, brand, category, unsubscribe);
if(approved)
{
html = restrictImages(html);
}
String text = version.getValueString("textSource") == null ? HtmlToText.convert(html)
: engine.parse(version.getValueString("textSource"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
if((long) subject.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + html.getBytes(java.nio.charset.StandardCharsets.UTF_8).length
+ text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
return (new QRecord().withValue("subject", approved && encrypt ? EmailBodyEncryption.encrypt(subject, "subject", messageId) : subject)
.withValue("htmlBody", approved && encrypt ? EmailBodyEncryption.encrypt(html, "htmlBody", messageId) : html)
.withValue("textBody", approved && encrypt ? EmailBodyEncryption.encrypt(text, "textBody", messageId) : text)
.withValue("variablesSnapshot", VariableSchema.json(snapshot(values, "", messageId)))
.withValue("templateId", template.getValue("id")).withValue("templateVersionId", version.getValue("id"))
.withValue("brandId", brand.getValue("id")).withValue("locale", version.getValue("locale"))
.withValue("category", category.name()).withValue("phiApproved", approved));
}
/*******************************************************************************
** Recursive redaction prevents nested records or lists bypassing tokenization.
*******************************************************************************/
private Object snapshot(Object value, String path, String messageId)
{
if(!path.isEmpty() && (EmailClassifications.sensitive(classification(path)) || unclassifiedRecordField(path)))
{
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + ".") && !path.substring(record.getKey().length() + 1).contains("."))
{
QTableMetaData table = QContext.getQInstance().getTable(record.getValue().table());
var field = table.getField(path.substring(record.getKey().length() + 1));
if(field != null)
{
return (EmailValueTokens.forRecordField(table, field, record.getValue().id(), (Serializable) value));
}
}
}
return (EmailValueTokens.forContext(List.of("email", template.getValueString("name"), path, messageId), (Serializable) value));
}
if(value instanceof Map<?, ?> map)
{
Map<String, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(key.toString(), snapshot(item, path.isEmpty() ? key.toString() : path + "." + key, messageId)));
return (result);
}
if(value instanceof List<?> list)
{
List<Object> result = new ArrayList<>();
list.forEach(item -> result.add(snapshot(item, path, messageId)));
return (result);
}
return (value == null || value instanceof Number || value instanceof Boolean ? value : value.toString());
}
/*******************************************************************************
** Missing record classification fails closed only in the retained snapshot.
*******************************************************************************/
private boolean unclassifiedRecordField(String path)
{
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + "."))
{
String field = path.substring(record.getKey().length() + 1).split("\\.")[0];
return (!EmailClassifications.hasSource(QContext.getQInstance().getTable(record.getValue().table()), field, EmailRuntime.config()));
}
}
return (false);
}
/*******************************************************************************
** Warnings contain binding names and policy reasons, never input values.
*******************************************************************************/
public List<String> warnings()
{
return (List.copyOf(new java.util.LinkedHashSet<>(warnings)));
}
/*******************************************************************************
** Apply the image policy after Markdown, layout and brand footer expansion.
*******************************************************************************/
private String restrictImages(String html)
{
var document = org.jsoup.Jsoup.parse(html);
document.outputSettings().prettyPrint(false);
document.select("[src],[background],[srcset],image").forEach(element ->
{
element.removeAttr("srcset");
if(element.hasAttr("src") && !EmailBrands.allowedImage(element.attr("src"), brand))
{
element.remove();
warnings.add("Image omitted: image host is not allowed");
}
if(element.hasAttr("background") && !EmailBrands.allowedImage(element.attr("background"), brand))
{
element.removeAttr("background");
warnings.add("Image omitted: image host is not allowed");
}
if("image".equals(element.normalName()))
{
element.remove();
warnings.add("SVG image omitted from approved email");
}
});
org.jsoup.select.NodeTraversor.filter((node, depth) ->
{
if(node instanceof org.jsoup.nodes.Comment comment && comment.getData().toLowerCase(java.util.Locale.ROOT).contains("[if"))
{
warnings.add("Conditional markup omitted from approved email");
return (org.jsoup.select.NodeFilter.FilterResult.REMOVE);
}
return (org.jsoup.select.NodeFilter.FilterResult.CONTINUE);
}, document);
// CSS image expressions can use escapes and image-set, so approved content
// uses img elements instead of CSS images. Layout colors remain intact.
document.select("[style]").forEach(element ->
{
String style = element.attr("style");
if(style.toLowerCase(java.util.Locale.ROOT).contains("url") || style.contains("\\") || style.toLowerCase(java.util.Locale.ROOT).contains("image-set"))
{
element.removeAttr("style");
warnings.add("CSS image omitted from approved email");
}
});
document.select("style").forEach(element ->
{
String style = element.data();
if(style.toLowerCase(java.util.Locale.ROOT).contains("url") || style.contains("\\") || style.toLowerCase(java.util.Locale.ROOT).contains("@import") || style.toLowerCase(java.util.Locale.ROOT).contains("image-set"))
{
element.remove();
warnings.add("CSS image omitted from approved email");
}
});
return (document.outerHtml());
}
}
@@ -0,0 +1,118 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Entry point for other qbits to ship their templates. A template is created
** once; after that this class never touches it, so people's edits are safe.
*******************************************************************************/
public final class EmailTemplates
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTemplates()
{
}
/*******************************************************************************
** Create the template and an ACTIVE version 1 when no template of that name
** exists. The definition is validated like Activate Version (syntax, schema, a
** render with the samples, MJML compile); an invalid one is refused with
** plain reasons and nothing is written. Returns true only when this call
** created the template; the unique name arbitrates concurrent callers.
*******************************************************************************/
public static boolean ensure(EmailTemplateDefinition definition) throws QException
{
List<String> reasons = definition.problems();
if(!reasons.isEmpty())
{
throw (new IllegalArgumentException("Template definition refused: " + String.join("; ", reasons)));
}
return (SystemWrites.call(() -> create(definition)));
}
/*******************************************************************************
** The checked, transactional creation, run as the qbit.
*******************************************************************************/
private static boolean create(EmailTemplateDefinition definition) throws QException
{
if(exists(definition.getName()))
{
return (false);
}
QRecord template = new QRecord().withValue("name", definition.getName()).withValue("label", definition.getLabel())
.withValue("category", definition.getCategory().name()).withValue("kind", definition.getKind()).withValue("phiApproved", false)
.withValue("ownerQbit", definition.getOwnerQbit()).withValue("editPermissionName", definition.getEditPermissionName());
QRecord version = new QRecord().withValue("versionNumber", 1).withValue("status", "ACTIVE").withValue("locale", "en")
.withValue("subject", definition.getSubject()).withValue("bodySource", definition.getBody()).withValue("textSource", definition.getTextBody())
.withValue("variablesSchema", definition.getVariablesSchema()).withValue("sampleVariables", definition.getSampleVariables());
try
{
EmailVersions.validate(template, version);
}
catch(IllegalArgumentException e)
{
throw (new IllegalArgumentException("Template definition refused: " + e.getMessage()));
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
{
QRecord stored = SystemWrites.insert(EmailTables.TEMPLATE, List.of(template), tx).getRecords().getFirst();
if(stored.getValue("id") == null || stored.getErrors() != null && !stored.getErrors().isEmpty())
{
tx.rollback();
if(exists(definition.getName()))
{
return (false);
}
throw (new QException("Could not create template " + definition.getName()));
}
version.withValue("templateId", stored.getValue("id"));
QRecord storedVersion = SystemWrites.insert(EmailTables.VERSION, List.of(version), tx).getRecords().getFirst();
if(storedVersion.getValue("id") == null || storedVersion.getErrors() != null && !storedVersion.getErrors().isEmpty())
{
throw (new QException("Could not create template version for " + definition.getName()));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", stored.getValue("id")).withValue("activeVersionId", storedVersion.getValue("id")), tx);
tx.commit();
return (true);
}
}
/*******************************************************************************
** Whether a template of that name is already present.
*******************************************************************************/
private static boolean exists(String name) throws QException
{
return (!SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", name)).isEmpty());
}
}
@@ -0,0 +1,68 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Human-readable version labels, resolving parent names as the current viewer.
*******************************************************************************/
public class EmailVersionLabels implements TableCustomizerInterface
{
/*******************************************************************************
** Batch parents to avoid one query per version, without bypassing security.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
var ids = records.stream().map(r -> r.getValueInteger("templateId")).filter(Objects::nonNull).distinct().toList();
Map<Integer, String> names = new HashMap<>();
if(!ids.isEmpty())
{
List<QRecord> templates = new QueryAction().execute(new QueryInput(EmailTables.TEMPLATE)
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids))
.withFieldNamesToInclude(new HashSet<>(List.of("id", "name")))).getRecords();
templates.forEach(t -> names.put(t.getValueInteger("id"), t.getValueString("name")));
}
for(QRecord row : records)
{
String locale = row.getValueString("locale");
row.setRecordLabel(names.getOrDefault(row.getValueInteger("templateId"), "Template") + " v" + row.getValueInteger("versionNumber")
+ (locale == null || locale.isBlank() || "en".equals(locale) ? "" : " (" + locale + ")"));
}
return (records);
}
}
@@ -0,0 +1,215 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Atomic version lifecycle. Approval binds to exactly one immutable version.
*******************************************************************************/
public final class EmailVersions
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailVersions()
{
}
/*******************************************************************************
** Enforce both the qbit permission and the template's independent edit grant.
*******************************************************************************/
public static void requireEdit(QRecord template, boolean approval)
{
if(SystemWrites.active())
{
return;
}
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE))
{
throw (new IllegalArgumentException(approval ? "PHI approval needs email.admin" : "Editing templates needs email.manage"));
}
String permission = template.getValueString("editPermissionName");
if(permission != null && !permission.isBlank() && (QContext.getQSession() == null || !QContext.getQSession().hasPermission(permission)))
{
throw (new IllegalArgumentException("Editing this template needs " + permission));
}
}
/*******************************************************************************
** Serialize lifecycle operations across replicas using the template row.
*******************************************************************************/
private static QRecord lock(Integer templateId, QBackendTransaction tx) throws QException
{
QRecord template = EmailStore.get(EmailTables.TEMPLATE, templateId, tx);
if(template == null)
{
throw (new IllegalArgumentException("Template not found"));
}
requireEdit(template, false);
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", templateId).withValue("versionLock", 0), tx);
return (EmailStore.get(EmailTables.TEMPLATE, templateId, tx));
}
/*******************************************************************************
** Copy active (or latest) into a new draft, with a globally increasing number.
*******************************************************************************/
public static QRecord newVersion(Integer templateId) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(templateId, tx);
List<QRecord> versions = SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", templateId)
.withOrderBy(new QFilterOrderBy("versionNumber", false)), tx);
QRecord source = versions.stream().filter(v -> Objects.equals(template.getValue("activeVersionId"), v.getValue("id"))).findFirst()
.orElse(versions.isEmpty() ? new QRecord().withValue("locale", "en").withValue("variablesSchema", "[]").withValue("sampleVariables", "{}") : versions.getFirst());
QRecord draft = new QRecord();
for(String field : List.of("locale", "subject", "preheader", "bodySource", "textSource", "variablesSchema", "sampleVariables"))
{
draft.setValue(field, source.getValue(field));
}
draft.withValue("templateId", templateId).withValue("versionNumber", versions.isEmpty() ? 1 : versions.getFirst().getValueInteger("versionNumber") + 1)
.withValue("status", "DRAFT").withValue("createdBy", actor());
QRecord result = SystemWrites.insert(EmailTables.VERSION, List.of(draft), tx).getRecords().getFirst();
if(result.getValue("id") == null || result.getErrors() != null && !result.getErrors().isEmpty())
{
throw (new QException("Could not create template version"));
}
tx.commit();
return (result);
}
}
/*******************************************************************************
** Approval is granted to the selected draft or active version, never retired.
*******************************************************************************/
public static void approve(Integer versionId) throws QException
{
QRecord version = requiredVersion(versionId, null);
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(version.getValueInteger("templateId"), tx);
requireEdit(template, true);
version = requiredVersion(versionId, tx);
if("RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
{
throw (new IllegalArgumentException("Retired versions and MARKETING templates cannot be approved for PHI"));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id"))
.withValue("phiApproved", true).withValue("approvedVersionId", versionId).withValue("phiApprovedBy", actor())
.withValue("phiApprovedDate", EmailRuntime.clock().instant()), tx);
tx.commit();
}
}
/*******************************************************************************
** Validate samples and syntax, compile MJML, then atomically retire and activate.
*******************************************************************************/
public static void activate(Integer versionId) throws QException
{
QRecord initial = requiredVersion(versionId, null);
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(initial.getValueInteger("templateId"), tx);
QRecord version = requiredVersion(versionId, tx);
if(!"DRAFT".equals(version.getValueString("status")))
{
throw (new IllegalArgumentException("Only a DRAFT version can be activated"));
}
validate(template, version);
for(QRecord previous : SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", template.getValueInteger("id")), tx))
{
if("ACTIVE".equals(previous.getValueString("status")) && Objects.equals(version.getValue("locale"), previous.getValue("locale")))
{
EmailStore.update(EmailTables.VERSION, new QRecord().withValue("id", previous.getValue("id")).withValue("status", "RETIRED"), tx);
}
}
EmailStore.update(EmailTables.VERSION, new QRecord().withValue("id", versionId).withValue("status", "ACTIVE")
.withValue("compiledHtml", version.getValue("compiledHtml")).withValue("activatedDate", EmailRuntime.clock().instant()), tx);
QRecord change = new QRecord().withValue("id", template.getValue("id")).withValue("activeVersionId", versionId);
if(!Objects.equals(template.getValue("approvedVersionId"), versionId))
{
change.withValue("phiApproved", false).withValue("approvedVersionId", null).withValue("phiApprovedBy", null).withValue("phiApprovedDate", null);
}
EmailStore.update(EmailTables.TEMPLATE, change, tx);
tx.commit();
}
}
/*******************************************************************************
** Review validation is side-effect-free; compiled output stays in memory.
*******************************************************************************/
public static void validate(QRecord template, QRecord version) throws QException
{
requireEdit(template, false);
if("MJML".equals(template.getValueString("kind")))
{
CliMjmlCompiler.rejectIncludes(version.getValueString("bodySource"));
EmailTemplateEngine.validateHtml(version.getValueString("bodySource"));
if(EmailRuntime.config().getMjmlCompiler() == null)
{
throw (new IllegalArgumentException("MJML activation needs a configured compiler"));
}
new EmailTemplateEngine().parse(version.getValueString("bodySource"), new VariableSchema(version.getValueString("variablesSchema")));
version.setValue("compiledHtml", EmailRuntime.config().getMjmlCompiler().compile(version.getValueString("bodySource")));
}
EmailTemplateRenderer renderer = new EmailTemplateRenderer(template, version,
new EmailRequest().withVariables(VariableSchema.values(version.getValueString("sampleVariables"))));
renderer.render("preview", "https://example.invalid/unsubscribe/preview", false);
}
/*******************************************************************************
** Load a required version without exposing database diagnostics.
*******************************************************************************/
public static QRecord requiredVersion(Integer id, QBackendTransaction tx) throws QException
{
QRecord version = EmailStore.get(EmailTables.VERSION, id, tx);
if(version == null)
{
throw (new IllegalArgumentException("Template version not found"));
}
return (version);
}
/*******************************************************************************
** Stable session actor identity, with an explicit system fallback.
*******************************************************************************/
private static String actor()
{
return (QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference());
}
}
@@ -0,0 +1,34 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
/*******************************************************************************
** Activation-time compilation only. Implementations must not persist sources.
*******************************************************************************/
@FunctionalInterface
public interface MjmlCompilerInterface
{
/*******************************************************************************
** Return HTML or a content-free failure.
*******************************************************************************/
String compile(String source);
}
@@ -0,0 +1,212 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.io.Serializable;
import java.math.BigDecimal;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/*******************************************************************************
** Declarative variable contract. Validation errors never include input values.
*******************************************************************************/
public class VariableSchema
{
private final Map<String, Entry> entries = new LinkedHashMap<>();
private static final ObjectMapper JSON = new ObjectMapper();
/*******************************************************************************
** One schema binding, including optional record provenance.
*******************************************************************************/
public record Entry(String name, String label, String type, boolean required, String classification, String table, String field)
{
}
/*******************************************************************************
** Parse the JSON list and reject ambiguous or unsupported declarations.
*******************************************************************************/
public VariableSchema(String json)
{
try
{
List<Entry> parsed = JSON.readValue(json == null || json.isBlank() ? "[]" : json, new TypeReference<List<Entry>>() {});
for(Entry entry : parsed)
{
if(entry.name() == null || !entry.name().matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*")
|| !List.of("STRING", "NUMBER", "DATE", "DATE_TIME", "BOOLEAN", "URL", "LIST", "RECORD").contains(entry.type())
|| entries.putIfAbsent(entry.name(), entry) != null
|| entry.classification() != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(entry.classification())
|| (entry.table() == null) != (entry.field() == null))
{
throw (new IllegalArgumentException());
}
}
}
catch(Exception e)
{
throw (new IllegalArgumentException("Invalid variables schema"));
}
}
/*******************************************************************************
** Declared bindings in stable order.
*******************************************************************************/
public Map<String, Entry> entries()
{
return (Map.copyOf(entries));
}
/*******************************************************************************
** Built-ins and paths under declared records are known to the parser.
*******************************************************************************/
public boolean knows(String name)
{
if(name.equals("now") || name.equals("message.id") || name.equals("unsubscribe_url")
|| name.matches("brand\\.(label|name|site_url|logo_url|logo_dark_url|logo_alt|postal_address|primary_color|text_color|background_color|dark_background_color|dark_text_color|button_text_color|dark_primary_color|dark_button_text_color|footer_markdown)"))
{
return (true);
}
return (entries.containsKey(name) || entries.values().stream().anyMatch(e ->
("RECORD".equals(e.type()) || "LIST".equals(e.type())) && name.startsWith(e.name() + ".")));
}
/*******************************************************************************
** Validate and coerce declared values without discarding record structure.
*******************************************************************************/
public Map<String, Serializable> validate(Map<String, Serializable> values)
{
Map<String, Serializable> result = new LinkedHashMap<>(values);
for(Entry entry : entries.values())
{
Object value = EmailTemplateEngine.lookup(values, entry.name());
if(value == null || value instanceof String s && s.isEmpty())
{
if(entry.required())
{
throw (new IllegalArgumentException("Required variable " + entry.name() + " is missing"));
}
continue;
}
try
{
Serializable coerced = switch(entry.type())
{
case "STRING" -> value.toString();
case "NUMBER" -> new BigDecimal(value.toString());
case "DATE" -> LocalDate.parse(value.toString());
case "DATE_TIME" -> Instant.parse(value.toString());
case "BOOLEAN" -> booleanValue(value);
case "URL" -> url(value.toString());
case "LIST" -> (Serializable) (List<?>) value;
case "RECORD" -> (Serializable) (Map<?, ?>) value;
default -> throw (new IllegalArgumentException());
};
result.put(entry.name(), coerced);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Variable " + entry.name() + " must be " + entry.type()));
}
}
return (result);
}
/*******************************************************************************
** Strict boolean coercion avoids silently turning misspellings into false.
*******************************************************************************/
private static Boolean booleanValue(Object value)
{
if(!List.of("true", "false").contains(value.toString().toLowerCase(java.util.Locale.ROOT)))
{
throw (new IllegalArgumentException());
}
return (Boolean.valueOf(value.toString()));
}
/*******************************************************************************
** Only absolute web URLs are allowed in variable and brand URL fields.
*******************************************************************************/
public static String url(String value)
{
return (url(value, false));
}
/*******************************************************************************
** The same URL policy, optionally allowing mailto only for link destinations.
*******************************************************************************/
public static String url(String value, boolean allowMailto)
{
URI uri = URI.create(value);
if(allowMailto && "mailto".equalsIgnoreCase(uri.getScheme()) && !uri.getRawSchemeSpecificPart().isBlank()
&& !value.matches("(?is).*%(?:0a|0d).*"))
{
return (value);
}
if(uri.getScheme() == null || !List.of("http", "https").contains(uri.getScheme().toLowerCase(java.util.Locale.ROOT)) || uri.getHost() == null || uri.getUserInfo() != null)
{
throw (new IllegalArgumentException("URL must use http or https"));
}
return (value);
}
/*******************************************************************************
** Parse process inputs and sample values; never echo malformed JSON.
*******************************************************************************/
public static Map<String, Serializable> values(String json)
{
try
{
Map<String, Serializable> result = JSON.readValue(json == null || json.isBlank() ? "{}" : json, new TypeReference<LinkedHashMap<String, Serializable>>() {});
if(result == null)
{
throw (new IllegalArgumentException());
}
return (result);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Variables must be a JSON object"));
}
}
/*******************************************************************************
** Serialize safe snapshots using the existing Jackson dependency.
*******************************************************************************/
public static String json(Object value)
{
try
{
return (JSON.writeValueAsString(value));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Could not serialize email variables"));
}
}
}
@@ -0,0 +1,302 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Provider payloads become small recipient-level events, never message bodies.
*******************************************************************************/
public final class EmailEventParser
{
/*******************************************************************************
** Provider identifiers remain scoped to the provider during database matching.
*******************************************************************************/
public record Event(String externalId, String messageId, String providerMessageId, String type, String bounceType,
String recipient, Instant occurred, String detail, String url)
{
}
/*******************************************************************************
** Parse one provider request, including all SendGrid events and SES recipients.
*******************************************************************************/
public List<Event> parse(String kind, String body, Instant received)
{
List<Event> events = new ArrayList<>();
switch(kind)
{
case "SES" -> ses(new JSONObject(body), received, events);
case "POSTMARK" -> postmark(new JSONObject(body), received, events);
case "MAILGUN" -> mailgun(new JSONObject(body).getJSONObject("event-data"), received, events);
case "SENDGRID" ->
{
JSONArray batch = new JSONArray(body);
for(int i = 0; i < batch.length(); i++)
{
sendgrid(batch.getJSONObject(i), received, events);
}
}
default -> throw new IllegalArgumentException("Unsupported email event kind");
}
return (events);
}
/*******************************************************************************
** SES recipients live under the specific event, with mail.destination fallback for non-suppressive events only.
*******************************************************************************/
private static void ses(JSONObject envelope, Instant received, List<Event> events)
{
if(!"Notification".equals(envelope.getString("Type")))
{
return;
}
JSONObject payload = new JSONObject(envelope.getString("Message"));
String rawType = payload.optString("eventType", payload.optString("notificationType"));
String type = switch(rawType)
{
case "Delivery" -> "DELIVERED";
case "Bounce" -> "BOUNCED";
case "Complaint" -> "COMPLAINED";
case "Reject" -> "REJECTED";
case "DeliveryDelay" -> "DEFERRED";
case "Open" -> "OPENED";
case "Click" -> "CLICKED";
default -> null;
};
if(type == null)
{
return;
}
JSONObject mail = payload.getJSONObject("mail");
String key = "DeliveryDelay".equals(rawType) ? "deliveryDelay" : rawType.toLowerCase(Locale.ROOT);
JSONObject data = object(payload, key);
String bounce = "BOUNCED".equals(type) ? switch(data.optString("bounceType"))
{
case "Permanent" -> "HARD";
case "Transient" -> "SOFT";
default -> "UNDETERMINED";
} : null;
String messageId = null;
JSONArray headers = mail.optJSONArray("headers");
if(headers != null)
{
for(int i = 0; i < headers.length(); i++)
{
JSONObject entry = headers.getJSONObject(i);
if("X-QQQ-Email-Id".equalsIgnoreCase(entry.optString("name")))
{
messageId = entry.optString("value", null);
}
}
}
JSONArray recipients = data.optJSONArray(switch(type)
{
case "BOUNCED" -> "bouncedRecipients";
case "COMPLAINED" -> "complainedRecipients";
case "DEFERRED" -> "delayedRecipients";
default -> "recipients";
});
if(recipients == null && ("BOUNCED".equals(type) || "COMPLAINED".equals(type)))
{
// Never widen a bounce or complaint to every destination; the event carries no recipient and is skipped.
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, null,
time(data.opt("timestamp"), received), null, null));
return;
}
if(recipients == null)
{
recipients = mail.getJSONArray("destination");
}
for(int i = 0; i < recipients.length(); i++)
{
Object recipient = recipients.get(i);
String address = recipient instanceof JSONObject row ? row.getString("emailAddress") : recipient.toString();
String reason = recipient instanceof JSONObject row ? row.optString("diagnosticCode", data.optString("bounceSubType", null))
: data.optString("reason", data.optString("smtpResponse", data.optString("delayType", data.optString("complaintFeedbackType", null))));
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, address,
time(data.opt("timestamp"), received), detail(reason), "CLICKED".equals(type) ? data.optString("link", null) : null));
}
}
/*******************************************************************************
** Postmark subscription reactivation is informational, never an unsubscribe.
*******************************************************************************/
private static void postmark(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("RecordType"))
{
case "Delivery" -> "DELIVERED";
case "Bounce" -> "BOUNCED";
case "SpamComplaint" -> "COMPLAINED";
case "Open" -> "OPENED";
case "Click" -> "CLICKED";
case "SubscriptionChange" -> data.optBoolean("SuppressSending", false) ? "UNSUBSCRIBED" : null;
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? switch(data.optString("Type"))
{
case "HardBounce", "SpamNotification", "SpamComplaint", "BadEmailAddress", "ManuallyDeactivated" -> "HARD";
case "SoftBounce", "Transient", "DnsError", "MailboxFull" -> "SOFT";
default -> "UNDETERMINED";
} : null;
Object date = data.opt("DeliveredAt");
for(String field : List.of("BouncedAt", "ReceivedAt", "ChangedAt"))
{
if(data.has(field))
{
date = data.get(field);
}
}
String id = data.has("ID") ? data.get("RecordType") + ":" + data.get("ID") : fingerprint(data);
events.add(new Event(id, null, data.optString("MessageID", null), type, bounce,
data.optString("Recipient", data.optString("Email", null)), time(date, received),
detail(data.optString("Description", data.optString("Details", data.optString("SuppressionReason", null)))),
"CLICKED".equals(type) ? data.optString("OriginalLink", null) : null));
}
/*******************************************************************************
** SendGrid retains the provider message id prefix before its delivery suffix.
*******************************************************************************/
private static void sendgrid(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("event"))
{
case "delivered" -> "DELIVERED";
case "bounce" -> "BOUNCED";
case "dropped" -> "REJECTED";
case "deferred" -> "DEFERRED";
case "spamreport" -> "COMPLAINED";
case "open" -> "OPENED";
case "click" -> "CLICKED";
case "unsubscribe", "group_unsubscribe" -> "UNSUBSCRIBED";
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? ("blocked".equals(data.optString("type")) ? "SOFT" : "HARD") : null;
String providerMessage = data.optString("sg_message_id", null);
if(providerMessage != null && providerMessage.contains("."))
{
providerMessage = providerMessage.substring(0, providerMessage.indexOf('.'));
}
events.add(new Event(data.optString("sg_event_id", fingerprint(data)),
data.optString("qqq_email_id", object(data, "custom_args").optString("qqq_email_id", null)), providerMessage, type, bounce,
data.optString("email", null), time(data.opt("timestamp"), received), detail(data.optString("reason", data.optString("response", null))),
"CLICKED".equals(type) ? data.optString("url", null) : null));
}
/*******************************************************************************
** Mailgun MIME ids are normalized identically to the outbound transport.
*******************************************************************************/
private static void mailgun(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("event"))
{
case "delivered" -> "DELIVERED";
case "failed" -> "BOUNCED";
case "complained" -> "COMPLAINED";
case "opened" -> "OPENED";
case "clicked" -> "CLICKED";
case "unsubscribed" -> "UNSUBSCRIBED";
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? switch(data.optString("severity"))
{
case "permanent" -> "HARD";
case "temporary" -> "SOFT";
default -> "UNDETERMINED";
} : null;
JSONObject variables = object(data, "user-variables");
String providerMessage = object(object(data, "message"), "headers").optString("message-id", null);
if(providerMessage != null)
{
providerMessage = providerMessage.replaceAll("^<|>$", "");
}
events.add(new Event(data.optString("id", fingerprint(data)), variables.optString("qqq_email_id", variables.optString("v:qqq_email_id", null)),
providerMessage, type, bounce, data.optString("recipient", null), time(data.opt("timestamp"), received),
detail(data.optString("reason", object(data, "delivery-status").optString("message", null))), "CLICKED".equals(type) ? data.optString("url", null) : null));
}
/*******************************************************************************
** Optional nested objects contain no executable data.
*******************************************************************************/
private static JSONObject object(JSONObject parent, String key)
{
JSONObject value = parent.optJSONObject(key);
return (value == null ? new JSONObject() : value);
}
/*******************************************************************************
** Fall back to receipt time only when the provider supplied no timestamp.
*******************************************************************************/
private static Instant time(Object value, Instant fallback)
{
if(value == null || JSONObject.NULL.equals(value))
{
return (fallback);
}
if(value instanceof Number number)
{
return (Instant.ofEpochMilli((long) (number.doubleValue() * 1000)));
}
return (OffsetDateTime.parse(value.toString()).toInstant());
}
/*******************************************************************************
** Fallback identity for providers without a distinct event identifier.
*******************************************************************************/
private static String fingerprint(JSONObject data)
{
return (WebhookVerification.hash(data.toString().getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Mask complete addresses before truncation, including quoted local parts.
*******************************************************************************/
public static String detail(String reason)
{
if(reason == null)
{
return (null);
}
String masked = reason.replaceAll("(?i)(?:\"[^\"]*\"|[^\\s<>(),;:]+)@[^\\s<>(),;:]+", "[address]");
return (masked.substring(0, Math.min(500, masked.length())));
}
}
@@ -0,0 +1,333 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.delivery.EmailAlerts;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.InternetAddress;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Bounded, replica-safe database transactions apply inbox rows exactly once.
** No network or external effects occur while the row's provider is locked.
*******************************************************************************/
public final class EmailInboxProcessor
{
private static final QLogger LOG = QLogger.getLogger(EmailInboxProcessor.class);
private static final int MAX_ATTEMPTS = 6;
/*******************************************************************************
** Poll due rows; transaction rollback releases claims after a worker crash.
*******************************************************************************/
public int runOnce() throws QException
{
int processed = 0;
for(QRecord row : SystemWrites.query(EmailTables.INBOX, EmailStore.equal("status", "PENDING")
.withCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())
.withOrderBy(new QFilterOrderBy("id")).withLimit(EmailRuntime.config().getBatchSize())))
{
if(process(row))
{
processed++;
}
}
return (processed);
}
/*******************************************************************************
** Re-read eligibility after the lock, including the observed attempt number.
*******************************************************************************/
private static boolean due(QRecord current, QRecord observed)
{
return (current != null && "PENDING".equals(current.getValueString("status"))
&& Objects.equals(current.getValue("attempts"), observed.getValue("attempts"))
&& current.getValueInstant("nextAttemptDate") != null && !current.getValueInstant("nextAttemptDate").isAfter(EmailRuntime.clock().instant()));
}
/*******************************************************************************
** All event, message, suppression and receipt changes commit together.
*******************************************************************************/
private boolean process(QRecord observed) throws QException
{
boolean complaint = false;
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
QRecord provider = EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
if(!due(row, observed))
{
return (false);
}
int applied = 0;
int unmatched = 0;
int skipped = 0;
for(EmailEventParser.Event event : new EmailEventParser().parse(row.getValueString("kind"), row.getValueString("payloadJson"), row.getValueInstant("receivedDate")))
{
if(("OPENED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackOpens")))
|| ("CLICKED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackClicks"))))
{
continue;
}
QRecord message = match(event, provider.getValueInteger("id"), tx);
if(message == null || "SENDING".equals(message.getValueString("status")))
{
unmatched++;
continue;
}
String recipient = normalize(event.recipient());
if(recipient == null || !recipients(message).contains(recipient))
{
LOG.info("Ignoring an email event whose recipient is not on the message", logPair("messageId", message.getValue("id")));
skipped++;
continue;
}
if(Boolean.TRUE.equals(message.getValueBoolean("phiApproved")) && List.of("OPENED", "CLICKED").contains(event.type()))
{
continue;
}
String key = provider.getValueInteger("id") + ":" + WebhookVerification.hash((event.externalId() + "\n" + event.type() + "\n"
+ recipient).getBytes(StandardCharsets.UTF_8));
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
if(stored == null)
{
continue;
}
applied++;
updateMessage(message, stored, tx);
if("COMPLAINED".equals(event.type()) || ("BOUNCED".equals(event.type()) && "HARD".equals(event.bounceType())))
{
EmailTrackingStore.suppress(message, stored, "ALL", "COMPLAINED".equals(event.type()) ? "COMPLAINT" : "HARD_BOUNCE", tx);
complaint |= "COMPLAINED".equals(event.type());
}
else if("UNSUBSCRIBED".equals(event.type()))
{
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
}
}
int attempts = row.getValueInteger("attempts") + 1;
boolean retry = unmatched > 0 && attempts < MAX_ATTEMPTS;
List<String> notes = new ArrayList<>();
if(unmatched > 0)
{
notes.add(unmatched + (unmatched == 1 ? " event did not match a message" : " events did not match a message"));
}
if(skipped > 0)
{
notes.add(skipped + (skipped == 1 ? " event was skipped (recipient not on the message)" : " events were skipped (recipient not on the message)"));
}
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id"))
.withValue("status", retry ? "PENDING" : (applied == 0 && unmatched == 0 ? "IGNORED" : "PROCESSED")).withValue("attempts", attempts)
.withValue("processedDate", retry ? null : EmailRuntime.clock().instant())
.withValue("nextAttemptDate", retry ? EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts)) : null)
.withValue("lastError", notes.isEmpty() ? null : String.join("; ", notes)), tx);
tx.commit();
}
catch(Exception e)
{
fail(observed);
return (false);
}
if(complaint)
{
complaintAlert();
}
return (true);
}
/*******************************************************************************
** Canonical mailbox, or null when the provider supplied none that is valid.
*******************************************************************************/
private static String normalize(String value)
{
if(value == null || value.isBlank())
{
return (null);
}
try
{
return (EmailTrackingStore.address(value));
}
catch(IllegalArgumentException e)
{
return (null);
}
}
/*******************************************************************************
** The canonical to, cc and bcc mailboxes of a message; only these may be
** suppressed by an event matched to it.
*******************************************************************************/
private static Set<String> recipients(QRecord message)
{
Set<String> set = new HashSet<>();
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
{
String value = message.getValueString(field);
if(value == null || value.isBlank())
{
continue;
}
try
{
for(InternetAddress address : InternetAddress.parse(value, false))
{
set.add(address.getAddress().toLowerCase(Locale.ROOT));
}
}
catch(jakarta.mail.internet.AddressException e)
{
// A malformed stored envelope contributes no recipients.
}
}
return (set);
}
/*******************************************************************************
** Match our id first, then the provider id; neither may cross providers.
*******************************************************************************/
private static QRecord match(EmailEventParser.Event event, Integer providerId, QBackendTransaction tx) throws QException
{
if(event.messageId() != null && event.messageId().matches("[0-9]{1,10}") && Long.parseLong(event.messageId()) <= Integer.MAX_VALUE)
{
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(event.messageId()), tx);
if(message != null && providerId.equals(message.getValueInteger("providerId")))
{
return (message);
}
}
if(event.providerMessageId() == null || event.providerMessageId().isBlank())
{
return (null);
}
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("providerId", providerId)
.withCriteria("providerMessageId", QCriteriaOperator.EQUALS, event.providerMessageId()), tx);
return (rows.size() == 1 ? rows.getFirst() : null);
}
/*******************************************************************************
** Complaint outranks hard bounce, which outranks delivery; never regress.
*******************************************************************************/
private static void updateMessage(QRecord message, QRecord event, QBackendTransaction tx) throws QException
{
String type = event.getValueString("eventType");
String status = message.getValueString("status");
String field = switch(type)
{
case "DELIVERED" -> "SENT".equals(status) ? "deliveredDate" : null;
case "BOUNCED" -> "HARD".equals(event.getValueString("bounceType")) && List.of("SENT", "DELIVERED").contains(status) ? "bouncedDate" : null;
case "COMPLAINED" -> List.of("SENT", "DELIVERED", "BOUNCED").contains(status) ? "complainedDate" : null;
default -> null;
};
if(field != null)
{
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("status", type)
.withValue(field, event.getValue("occurredDate")), tx);
}
}
/*******************************************************************************
** After rollback, only the still-due observed attempt may consume retry budget.
*******************************************************************************/
private static void fail(QRecord observed) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
if(due(row, observed))
{
int attempts = row.getValueInteger("attempts") + 1;
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts)
.withValue("status", attempts >= MAX_ATTEMPTS ? "FAILED" : "PENDING").withValue("lastError", "Email event processing failed; check message matching and payload validity.")
.withValue("nextAttemptDate", attempts >= MAX_ATTEMPTS ? null : EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts))), tx);
tx.commit();
}
}
}
/*******************************************************************************
** Standard Reprocess action rechecks FAILED under the same transaction lock.
*******************************************************************************/
public static boolean reprocess(Integer id) throws QException
{
QRecord selected = EmailStore.get(EmailTables.INBOX, id, null);
if(selected == null)
{
return (false);
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(selected.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, id, tx);
if(!"FAILED".equals(row.getValueString("status")))
{
return (false);
}
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", id).withValue("status", "PENDING").withValue("attempts", 0)
.withValue("nextAttemptDate", EmailRuntime.clock().instant()).withValue("lastError", null).withValue("processedDate", null), tx);
tx.commit();
return (true);
}
}
/*******************************************************************************
** Optional alerting cannot turn a committed event into a failed inbox row.
*******************************************************************************/
private static void complaintAlert()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.alerts.QAlerts");
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventType", "COMPLAINED")
.withCriteria("occurredDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, EmailRuntime.clock().instant().minusSeconds(86400))
.withCriteria("occurredDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())) > 3)
{
EmailAlerts.raise(EmailAlerts.COMPLAINT, 0);
}
}
catch(Exception | LinkageError e)
{
// Alert integration is optional; event data is already committed.
}
}
}
@@ -0,0 +1,218 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** DB-only receipt, event deduplication and suppression writes.
*******************************************************************************/
public final class EmailTrackingStore
{
/*******************************************************************************
** Distinguish an authenticated retry from reuse of a Mailgun nonce.
*******************************************************************************/
public enum Receipt
{
RECEIVED, DUPLICATE, REPLAY
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTrackingStore()
{
}
/*******************************************************************************
** Serialize address upserts across providers, then acquire the dispatcher lock.
** Every tracking writer uses this order and does no network I/O while locked.
*******************************************************************************/
public static QRecord lock(Integer providerId, QBackendTransaction tx) throws QException
{
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(1), tx);
if(providers.isEmpty())
{
throw (new QException("Email provider unavailable"));
}
EmailStore.lockProvider(providers.getFirst().getValueInteger("id"), tx);
QRecord provider = EmailStore.lockProvider(providerId, tx);
if(provider == null)
{
throw (new QException("Email provider unavailable"));
}
return (provider);
}
/*******************************************************************************
** A provider lock avoids expected duplicate SQL errors (and payload logging).
** Unique constraints remain the final database guard.
*******************************************************************************/
public static Receipt receive(QRecord provider, String externalId, String nonce, byte[] bytes, String note) throws QException
{
Integer providerId = provider.getValueInteger("id");
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailStore.lockProvider(providerId, tx);
QQueryFilter filter = EmailStore.equal("providerId", providerId);
if(nonce != null && SystemWrites.count(EmailTables.INBOX, filter.clone().withCriteria("nonce", QCriteriaOperator.EQUALS, nonce), tx) > 0)
{
return (Receipt.REPLAY);
}
if(SystemWrites.count(EmailTables.INBOX, filter.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId), tx) > 0)
{
return (Receipt.DUPLICATE);
}
var now = EmailRuntime.clock().instant();
QRecord row = new QRecord().withValue("providerId", providerId).withValue("kind", provider.getValue("kind"))
.withValue("externalEventId", externalId).withValue("nonce", nonce).withValue("status", note == null ? "PENDING" : "IGNORED")
.withValue("attempts", 0).withValue("nextAttemptDate", note == null ? now : null).withValue("lastError", note)
.withValue("payloadJson", new String(bytes, StandardCharsets.UTF_8)).withValue("payloadSha256", WebhookVerification.hash(bytes))
.withValue("receivedDate", now).withValue("processedDate", note == null ? null : now);
copySecurity(provider, row);
insert(EmailTables.INBOX, row, tx);
tx.commit();
return (Receipt.RECEIVED);
}
catch(QException e)
{
// Re-query after rollback: a unique violation aborts a PostgreSQL transaction.
if(nonce != null && SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
.withCriteria("nonce", QCriteriaOperator.EQUALS, nonce)) > 0)
{
return (Receipt.REPLAY);
}
if(SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId)) > 0)
{
return (Receipt.DUPLICATE);
}
throw (new QException("Email inbox write failed"));
}
}
/*******************************************************************************
** Fail atomically without exposing input values or database diagnostics.
*******************************************************************************/
public static QRecord insert(String table, QRecord row, QBackendTransaction tx) throws QException
{
List<QRecord> records = SystemWrites.insert(table, List.of(row), tx).getRecords();
if(records.isEmpty() || records.getFirst().getValueInteger("id") == null || (records.getFirst().getErrors() != null && !records.getFirst().getErrors().isEmpty()))
{
throw (new QException("Email tracking write failed"));
}
return (records.getFirst());
}
/*******************************************************************************
** Preserve host tenant security on child and suppression records.
*******************************************************************************/
public static void copySecurity(QRecord source, QRecord target)
{
for(var field : EmailRuntime.config().getSecurityFields())
{
target.withValue(field.getName(), source.getValue(field.getName()));
}
}
/*******************************************************************************
** Insert a recipient event once even across overlapping provider batches.
*******************************************************************************/
public static QRecord event(QRecord message, EmailEventParser.Event event, String key, QBackendTransaction tx) throws QException
{
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx) > 0)
{
return (null);
}
QRecord row = new QRecord().withValue("messageId", message.getValue("id")).withValue("providerId", message.getValue("providerId"))
.withValue("eventType", event.type()).withValue("bounceType", event.bounceType()).withValue("recipient", address(event.recipient()))
.withValue("occurredDate", event.occurred()).withValue("detail", event.detail()).withValue("url", "CLICKED".equals(event.type()) ? event.url() : null)
.withValue("eventKey", key);
copySecurity(message, row);
return (insert(EmailTables.EVENT, row, tx));
}
/*******************************************************************************
** Upsert without weakening a complaint or shortening an existing suppression.
** All callers hold the common tracking lock throughout their transaction.
*******************************************************************************/
public static void suppress(QRecord message, QRecord event, String scope, String reason, QBackendTransaction tx) throws QException
{
if("UNSUBSCRIBE".equals(reason) && !List.of("MARKETING", "NOTIFICATION").contains(scope == null ? "" : scope))
{
return;
}
String address = event.getValueString("recipient");
List<QRecord> existing = SystemWrites.query(EmailTables.SUPPRESSION, EmailStore.equal("address", address)
.withCriteria("scope", QCriteriaOperator.EQUALS, scope), tx);
QRecord row = new QRecord().withValue("address", address).withValue("scope", scope).withValue("reason", reason)
.withValue("sourceMessageId", message.getValue("id")).withValue("sourceEventId", event.getValue("id")).withValue("expiresDate", null);
copySecurity(message, row);
if(existing.isEmpty())
{
row.withValue("createdDate", EmailRuntime.clock().instant());
insert(EmailTables.SUPPRESSION, row, tx);
}
else
{
QRecord old = existing.getFirst();
if("COMPLAINT".equals(old.getValueString("reason")))
{
row.withValue("reason", "COMPLAINT");
}
row.withValue("id", old.getValue("id"));
EmailStore.update(EmailTables.SUPPRESSION, row, tx);
}
}
/*******************************************************************************
** Canonical mailbox for event deduplication and suppression lookup.
*******************************************************************************/
public static String address(String value)
{
try
{
InternetAddress address = new InternetAddress(value, true);
address.validate();
return (address.getAddress().toLowerCase(Locale.ROOT));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Email event recipient is invalid"));
}
}
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Stateless token validation and atomic, idempotent category opt-out.
*******************************************************************************/
public final class EmailUnsubscribe
{
/*******************************************************************************
** Resolve the signed message and its single recipient, without writes.
*******************************************************************************/
public QRecord resolve(String token) throws QException
{
return (resolve(token, null));
}
/*******************************************************************************
** Recheck the binding against the same transaction used for suppression.
*******************************************************************************/
private QRecord resolve(String token, QBackendTransaction transaction) throws QException
{
JSONObject payload = EmailUnsubscribeLinks.verify(token);
if(payload == null)
{
return (null);
}
try
{
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(payload.getString("messageId")), transaction);
if(message == null || !payload.getString("category").equals(message.getValueString("category")))
{
return (null);
}
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
String hash = payload.getString("addressHash");
// Safe legacy links remain valid only after the single-mailbox check above.
String legacyEnvelope = message.getValueString("toAddresses") + "," + message.getValueString("ccAddresses") + "," + message.getValueString("bccAddresses");
boolean matches = WebhookVerification.equal(hash, WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8)))
|| WebhookVerification.equal(hash, WebhookVerification.hash(legacyEnvelope.getBytes(StandardCharsets.UTF_8)));
return (matches ? message : null);
}
catch(IllegalArgumentException | org.json.JSONException e)
{
return (null);
}
}
/*******************************************************************************
** Opt out only the signed mailbox; reject changed or multi-recipient envelopes.
** Never display those addresses on the public page or emit them to logs.
*******************************************************************************/
public void unsubscribe(String token) throws QException
{
QRecord initial = resolve(token);
if(initial == null)
{
throw (new IllegalArgumentException("Invalid unsubscribe link"));
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(initial.getValueInteger("providerId"), tx);
QRecord message = resolve(token, tx);
if(message == null || !initial.getValueInteger("providerId").equals(message.getValueInteger("providerId")))
{
throw (new IllegalArgumentException("Invalid unsubscribe link"));
}
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
String key = "unsubscribe:" + message.getValueInteger("id") + ":" + WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8));
var event = new EmailEventParser.Event(key, message.getValueString("id"), null, "UNSUBSCRIBED", null, recipient, EmailRuntime.clock().instant(), null, null);
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
if(stored == null)
{
stored = com.kingsrook.qbits.email.support.SystemWrites.query(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx).getFirst();
}
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
tx.commit();
}
}
/*******************************************************************************
** Self-contained accessible page; an empty form action posts to this same URL.
*******************************************************************************/
public String page(QRecord message, boolean confirmed) throws QException
{
QRecord brand = message.getValueInteger("brandId") == null ? null : EmailStore.get(EmailTables.BRAND, message.getValueInteger("brandId"), null);
String label = brand == null || brand.getValueString("label") == null ? EmailRuntime.config().getAppLabel() : brand.getValueString("label");
String category = "MARKETING".equals(message.getValueString("category")) ? "Marketing" : "Notification";
String question = confirmed ? "You are unsubscribed from " + category + " email from " + label + "."
: "Unsubscribe from " + category + " email from " + label + "?";
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<meta name=\"color-scheme\" content=\"light dark\"><title>Email preferences</title><style>"
+ ":root{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;padding:20px;font:18px/1.5 system-ui;background:#fff;color:#172033}"
+ "main{max-width:560px;margin:40px auto;overflow-wrap:anywhere}h1{font-size:1.5rem}button{max-width:100%;padding:12px 20px;font:inherit;background:#174ea6;color:#fff;border:0;border-radius:6px;cursor:pointer}"
+ "@media(prefers-color-scheme:dark){body{background:#111827;color:#f9fafb}button{background:#a9c7ff;color:#111827}}</style></head><body><main><p>"
+ EmailTemplateEngine.escape(label) + "</p><h1>" + EmailTemplateEngine.escape(question) + "</h1>"
+ (confirmed ? "" : "<form method=\"post\"><input type=\"hidden\" name=\"confirm\" value=\"unsubscribe\"><button type=\"submit\">Unsubscribe</button></form>")
+ "</main></body></html>");
}
}
@@ -0,0 +1,65 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import org.json.JSONObject;
/*******************************************************************************
** Authenticate Mailgun; the durable inbox separately arbitrates nonce replay.
*******************************************************************************/
public final class MailgunVerifier
{
/*******************************************************************************
** Verify timestamp, token and hexadecimal HMAC without retaining the key.
*******************************************************************************/
public boolean verify(JSONObject payload, String secret, Instant now)
{
if(secret == null || secret.isBlank())
{
return (false);
}
try
{
JSONObject signed = payload.getJSONObject("signature");
String timestamp = signed.get("timestamp").toString();
String token = signed.getString("token");
if(token.isBlank() || token.length() > 500 || !WebhookVerification.fresh(timestamp, now))
{
return (false);
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal((timestamp + token).getBytes(StandardCharsets.UTF_8)));
return (WebhookVerification.equal(expected, signed.getString("signature")));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -0,0 +1,52 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/*******************************************************************************
** Postmark authenticates with fixed username postmark and a resolved password.
*******************************************************************************/
public final class PostmarkVerifier
{
/*******************************************************************************
** Verify HTTP Basic credentials without logging or returning either value.
*******************************************************************************/
public boolean verify(String authorization, String secret)
{
if(secret == null || secret.isBlank() || authorization == null || !authorization.regionMatches(true, 0, "Basic ", 0, 6))
{
return (false);
}
try
{
String credentials = new String(Base64.getDecoder().decode(authorization.substring(6)), StandardCharsets.UTF_8);
return (WebhookVerification.equal("postmark:" + secret, credentials));
}
catch(IllegalArgumentException e)
{
return (false);
}
}
}
@@ -0,0 +1,60 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
/*******************************************************************************
** Verify SendGrid's timestamp and raw body with its DER encoded EC public key.
*******************************************************************************/
public final class SendGridVerifier
{
/*******************************************************************************
** The timestamp bytes precede the unchanged request body.
*******************************************************************************/
public boolean verify(byte[] body, String signature, String timestamp, String secret, Instant now)
{
if(secret == null || secret.isBlank() || signature == null || !WebhookVerification.fresh(timestamp, now))
{
return (false);
}
try
{
var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(secret)));
Signature verifier = Signature.getInstance("SHA256withECDSA");
verifier.initVerify(key);
verifier.update(timestamp.getBytes(StandardCharsets.UTF_8));
verifier.update(body);
return (verifier.verify(Base64.getDecoder().decode(signature)));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -0,0 +1,219 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.email.transport.EmailHttp;
import org.json.JSONObject;
/*******************************************************************************
** SNS verification with HTTPS AWS-only certificate retrieval, memory caching (including
** a five-minute cache of fetch failures) and a freshness window on the signed Timestamp.
*******************************************************************************/
public final class SnsVerifier
{
private final EmailHttp http;
private static final Duration MAX_AGE = Duration.ofHours(1);
private static final Duration MAX_FUTURE = Duration.ofMinutes(5);
private static final Duration FAILURE_TTL = Duration.ofMinutes(5);
private final Map<String, X509Certificate> certificates = new ConcurrentHashMap<>();
private final Map<String, Instant> failures = new ConcurrentHashMap<>();
/*******************************************************************************
** Default bounded HTTPS transport without redirects.
*******************************************************************************/
public SnsVerifier()
{
this(EmailHttp::sendJdk);
}
/*******************************************************************************
** Inject HTTP for deterministic certificate and confirmation tests.
*******************************************************************************/
public SnsVerifier(EmailHttp http)
{
this.http = http;
}
/*******************************************************************************
** Strict URL boundary applied before every network operation.
*******************************************************************************/
public static boolean allowedUrl(String value, boolean certificate)
{
try
{
URI uri = URI.create(value);
return ("https".equals(uri.getScheme()) && uri.getHost() != null
&& uri.getHost().matches("^sns\\.[a-z0-9-]+\\.amazonaws\\.com(\\.cn)?$")
&& uri.getUserInfo() == null && (uri.getPort() == -1 || uri.getPort() == 443) && uri.getFragment() == null
&& (!certificate || (uri.getPath().endsWith(".pem") && uri.getQuery() == null)));
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** AWS's fixed order includes the final newline and optional Subject only.
*******************************************************************************/
public static String canonical(JSONObject payload)
{
String type = payload.getString("Type");
List<String> fields;
if("Notification".equals(type))
{
fields = payload.has("Subject") ? List.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type")
: List.of("Message", "MessageId", "Timestamp", "TopicArn", "Type");
}
else if("SubscriptionConfirmation".equals(type) || "UnsubscribeConfirmation".equals(type))
{
fields = List.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type");
}
else
{
throw (new IllegalArgumentException("Unsupported SNS envelope"));
}
StringBuilder value = new StringBuilder();
for(String field : fields)
{
value.append(field).append('\n').append(payload.getString(field)).append('\n');
}
return (value.toString());
}
/*******************************************************************************
** Authenticate the topic and signature; no confirmation before success.
*******************************************************************************/
public boolean verify(JSONObject payload, String topic, Instant now) throws IOException, InterruptedException
{
try
{
String url = payload.getString("SigningCertURL");
if(topic == null || topic.isBlank() || !topic.equals(payload.getString("TopicArn")) || !allowedUrl(url, true))
{
return (false);
}
String algorithm = switch(payload.getString("SignatureVersion"))
{
case "1" -> "SHA1withRSA";
case "2" -> "SHA256withRSA";
default -> null;
};
if(algorithm == null)
{
return (false);
}
Instant stamp = Instant.parse(payload.getString("Timestamp"));
if(stamp.isBefore(now.minus(MAX_AGE)) || stamp.isAfter(now.plus(MAX_FUTURE)))
{
return (false);
}
String canonical = canonical(payload);
X509Certificate certificate = certificates.get(url);
if(certificate == null || certificate.getNotAfter().toInstant().isBefore(now))
{
Instant failed = failures.get(url);
if(failed != null && failed.plus(FAILURE_TTL).isAfter(now))
{
throw (new IOException("SNS verification service unavailable"));
}
byte[] pem;
try
{
pem = get(url).getBytes(StandardCharsets.UTF_8);
}
catch(IOException e)
{
if(failures.size() >= 64)
{
failures.clear();
}
failures.put(url, now);
throw (e);
}
certificate = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(pem));
certificate.checkValidity(Date.from(now));
if(certificates.size() >= 64)
{
certificates.clear();
}
certificates.put(url, certificate);
}
certificate.checkValidity(Date.from(now));
Signature verifier = Signature.getInstance(algorithm);
verifier.initVerify(certificate.getPublicKey());
verifier.update(canonical.getBytes(StandardCharsets.UTF_8));
return (verifier.verify(Base64.getDecoder().decode(payload.getString("Signature"))));
}
catch(IOException | InterruptedException e)
{
throw (e);
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
** Caller invokes this only after verifying the signed confirmation envelope.
*******************************************************************************/
public void confirm(JSONObject payload) throws IOException, InterruptedException
{
String url = payload.getString("SubscribeURL");
if(!allowedUrl(url, false))
{
throw (new IllegalArgumentException("Invalid SNS confirmation URL"));
}
get(url);
}
/*******************************************************************************
** Fixed request timeout, no redirects, and no provider response diagnostics.
*******************************************************************************/
private String get(String url) throws IOException, InterruptedException
{
var response = http.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(30)).GET().build());
if(response.statusCode() != 200 || response.body() == null || response.body().length() > 65536)
{
throw (new IOException("SNS verification service unavailable"));
}
return (response.body());
}
}
@@ -0,0 +1,105 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Small shared verification primitives. Errors never retain request data.
*******************************************************************************/
public final class WebhookVerification
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private WebhookVerification()
{
}
/*******************************************************************************
** Resolve only an allowlisted webhook secret; null means unavailable.
*******************************************************************************/
public static String secret(QRecord provider)
{
EmailQBitConfig config = EmailRuntime.config();
String name = provider.getValueString("webhookSecretEnvName");
try
{
if(name != null && EmailQBitConfig.ENV_NAME.matcher(name).matches() && config.effectiveAllowedEnvNames(provider.getValueString("kind")).contains(name))
{
String value = config.getSecretResolver().apply(name);
return (value == null || value.isBlank() ? null : value);
}
}
catch(RuntimeException e)
{
return (null);
}
return (null);
}
/*******************************************************************************
** Compare bytes without data-dependent early exits.
*******************************************************************************/
public static boolean equal(String expected, String actual)
{
return (expected != null && actual != null && MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Reject timestamps outside five minutes, including future timestamps.
*******************************************************************************/
public static boolean fresh(String timestamp, Instant now)
{
try
{
Instant time = Instant.ofEpochSecond(Long.parseLong(timestamp));
return (!time.isBefore(now.minusSeconds(300)) && !time.isAfter(now.plusSeconds(300)));
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** Stable payload and event fingerprints, never payload diagnostics.
*******************************************************************************/
public static String hash(byte[] bytes)
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
}
catch(Exception e)
{
throw (new IllegalStateException("SHA-256 unavailable"));
}
}
}
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/*******************************************************************************
** Shared HTTP seam; no provider SDK, credential cache or filesystem.
*******************************************************************************/
@FunctionalInterface
public interface EmailHttp
{
/*******************************************************************************
** Exchange one request without retries.
*******************************************************************************/
HttpResponse<String> send(HttpRequest request) throws IOException, InterruptedException;
/*******************************************************************************
** Production requests use HTTPS and never forward credentials on redirects.
*******************************************************************************/
static HttpResponse<String> sendJdk(HttpRequest request) throws IOException, InterruptedException
{
if(!"https".equalsIgnoreCase(request.uri().getScheme()))
{
throw (new IOException("Email HTTP requires HTTPS"));
}
try(HttpClient client = newClient())
{
return (client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)));
}
}
/*******************************************************************************
** A bounded, stateless JDK client; the caller closes it after each exchange.
*******************************************************************************/
static HttpClient newClient()
{
return (HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).followRedirects(HttpClient.Redirect.NEVER).build());
}
}
@@ -0,0 +1,230 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
/*******************************************************************************
** Shared serialization, timeouts and sanitized HTTP failures.
*******************************************************************************/
final class EmailHttpSupport
{
static final ObjectMapper JSON = new ObjectMapper();
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHttpSupport()
{
}
/*******************************************************************************
** Use the shared envelope validation even for JSON APIs.
*******************************************************************************/
static void validate(EmailEnvelope envelope) throws EmailTransportException
{
try
{
EmailMime.build(Session.getInstance(new Properties()), envelope);
}
catch(MessagingException | RuntimeException e)
{
throw (new EmailTransportException("Invalid email message", true));
}
}
/*******************************************************************************
** Preserve application headers and the stable correlation identifier.
*******************************************************************************/
static Map<String, String> headers(EmailEnvelope envelope)
{
Map<String, String> headers = new LinkedHashMap<>();
envelope.getHeaders().forEach((name, value) ->
{
if(!name.equalsIgnoreCase("Bcc") && !name.equalsIgnoreCase("Auto-Submitted") && !name.equalsIgnoreCase("X-QQQ-Email-Id"))
{
headers.put(name, value);
}
});
headers.put("Auto-Submitted", "auto-generated");
headers.put("X-QQQ-Email-Id", envelope.getMessageId());
return (headers);
}
/*******************************************************************************
** Serialize only in memory using Jackson already supplied by core.
*******************************************************************************/
static byte[] json(Object value) throws EmailTransportException
{
try
{
return (JSON.writeValueAsBytes(value));
}
catch(IOException | RuntimeException e)
{
throw (new EmailTransportException("Invalid email message", true));
}
}
/*******************************************************************************
** Resolve auth before calling; never attach raw exceptions to a safe failure.
*******************************************************************************/
static HttpResponse<String> post(EmailHttp http, String url, String contentType, String authHeader, String auth, byte[] body) throws EmailTransportException
{
try
{
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Content-Type", contentType).header("Accept", "application/json").header(authHeader, auth)
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
return (http.send(request));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new EmailTransportException("Email HTTP request interrupted", false));
}
catch(IOException e)
{
throw (new EmailTransportException("Email HTTP temporarily unavailable", false));
}
catch(RuntimeException e)
{
throw (new EmailTransportException("Email HTTP configuration invalid", false).withProviderLevel(true));
}
}
/*******************************************************************************
** Unparseable replies must not expose bodies or cause permanent message loss.
*******************************************************************************/
static JsonNode responseJson(HttpResponse<String> response) throws EmailTransportException
{
try
{
JsonNode json = JSON.readTree(response.body());
if(json != null && json.isObject())
{
return (json);
}
}
catch(IOException | RuntimeException e)
{
// Bodies and parser diagnostics may contain recipient addresses.
}
throw (new EmailTransportException("Email provider returned an invalid response", false));
}
/*******************************************************************************
** Parse either Retry-After syntax; the dispatcher applies the one-hour cap.
*******************************************************************************/
static Duration retryAfter(HttpResponse<String> response)
{
String value = response.headers().firstValue("Retry-After").orElse("").trim();
try
{
long seconds = Long.parseLong(value);
return (seconds < 0 ? null : Duration.ofSeconds(seconds));
}
catch(RuntimeException e)
{
try
{
Duration delay = Duration.between(Instant.now(), ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
return (delay.isNegative() ? Duration.ZERO : delay);
}
catch(RuntimeException ignored)
{
return (null);
}
}
}
/*******************************************************************************
** Only numeric status/code and fixed text escape the transport.
*******************************************************************************/
static EmailTransportException failure(HttpResponse<String> response, String provider, Integer code, boolean permanent, boolean providerLevel)
{
return (new EmailTransportException(provider + " request failed (HTTP " + response.statusCode()
+ (code == null ? "" : ", code " + code) + ")", permanent).withProviderLevel(providerLevel).withRetryAfter(retryAfter(response)));
}
/*******************************************************************************
** Common HTTP precedence: throttling and server errors always remain retryable.
*******************************************************************************/
static void checkStatus(HttpResponse<String> response, String provider, int success) throws EmailTransportException
{
int status = response.statusCode();
if(status != success)
{
boolean transientFailure = status == 408 || status == 429 || status >= 500;
boolean providerLevel = status == 401 || status == 403 || (status >= 300 && status < 400);
throw (failure(response, provider, null, !transientFailure && !providerLevel && status >= 400, providerLevel));
}
}
/*******************************************************************************
** A success must contain a usable id; never mistake a malformed body for success.
*******************************************************************************/
static EmailTransportResult result(String id) throws EmailTransportException
{
if(id == null || id.isBlank() || id.length() > 500 || id.contains("\r") || id.contains("\n"))
{
throw (new EmailTransportException("Email provider returned an invalid message id", false));
}
return (new EmailTransportResult(id));
}
}
@@ -86,6 +86,36 @@ public final class EmailProviderSettings
|| "127.0.0.1".equals(host()) || "::1".equals(host()));
}
/*******************************************************************************
** A domain is a single DNS name, never a URL or a path component escape.
*******************************************************************************/
public static boolean validSendingDomain(String domain)
{
return (domain != null && domain.length() <= 253
&& domain.matches("(?i)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+"));
}
/*******************************************************************************
** Tracking is opt-in; null database values mean disabled.
*******************************************************************************/
boolean enabled(String field)
{
return (Boolean.TRUE.equals(row.getValueBoolean(field)));
}
/*******************************************************************************
** HTTP providers must never send a missing token as the literal string null.
*******************************************************************************/
String requiredSecret() throws EmailTransportException
{
String secret = resolve("secretEnvName");
if(secret == null)
{
throw (new EmailTransportException("Provider needs a secret environment name", false).withProviderLevel(true));
}
return (secret);
}
/*******************************************************************************
** Resolve an explicitly named secret immediately before sending.
*******************************************************************************/
@@ -33,13 +33,16 @@ public class EmailTransportRegistry
private final Map<String, EmailTransport> transports = new HashMap<>();
/*******************************************************************************
** Install the phase (a) transports.
** Install the outbound transports.
*******************************************************************************/
public EmailTransportRegistry()
{
transports.put("SMTP", new SmtpEmailTransport());
transports.put("GMAIL_SMTP", new SmtpEmailTransport());
transports.put("SES", new SesEmailTransport());
transports.put("POSTMARK", new PostmarkEmailTransport());
transports.put("SENDGRID", new SendGridEmailTransport());
transports.put("MAILGUN", new MailgunEmailTransport());
}
/*******************************************************************************
@@ -59,7 +62,8 @@ public class EmailTransportRegistry
EmailTransport transport = transports.get(kind);
if(transport == null)
{
throw (new EmailTransportException("Provider transport not available until phase (b)", true));
throw (new EmailTransportException("GMAIL_API".equals(kind)
? "Gmail API sending is specified but not built; use GMAIL_SMTP" : "Unknown email provider kind", true));
}
return (transport);
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Mailgun MIME upload, entirely in memory, with explicit blind destinations.
*******************************************************************************/
public class MailgunEmailTransport implements EmailTransport
{
private final EmailHttp http;
/*******************************************************************************
** Fixed regional production endpoints and JDK HTTP implementation.
*******************************************************************************/
public MailgunEmailTransport()
{
this(EmailHttp::sendJdk);
}
/*******************************************************************************
** Package-visible request/response test seam.
*******************************************************************************/
MailgunEmailTransport(EmailHttp http)
{
this.http = http;
}
/*******************************************************************************
** Envelope destinations include Bcc; serialized MIME never includes its header.
*******************************************************************************/
@Override
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
{
String domain = settings.value("sendingDomain");
String region = settings.value("region");
if(!EmailProviderSettings.validSendingDomain(domain) || (!"US".equals(region) && !"EU".equals(region)))
{
throw (new EmailTransportException("Mailgun needs a sending domain and US or EU region", false).withProviderLevel(true));
}
String boundary = "qqq-email-" + UUID.randomUUID();
ByteArrayOutputStream body = new ByteArrayOutputStream();
try
{
List<String> destinations = new ArrayList<>();
for(List<String> role : List.of(envelope.getTo(), envelope.getCc(), envelope.getBcc()))
{
for(String value : role)
{
for(InternetAddress address : InternetAddress.parse(value, true))
{
address.validate();
destinations.add(address.getAddress());
}
}
}
field(body, boundary, "to", String.join(",", destinations));
field(body, boundary, "o:tracking-opens", settings.enabled("trackOpens") ? "yes" : "no");
field(body, boundary, "o:tracking-clicks", settings.enabled("trackClicks") ? "yes" : "no");
field(body, boundary, "v:qqq_email_id", envelope.getMessageId());
body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"message\"; filename=\"message.eml\"\r\n"
+ "Content-Type: message/rfc822\r\n\r\n").getBytes(StandardCharsets.UTF_8));
EmailMime.build(Session.getInstance(new Properties()), envelope).writeTo(body, new String[] {"Bcc"});
body.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
}
catch(MessagingException | IOException | RuntimeException e)
{
throw (new EmailTransportException("Invalid email message", true));
}
String auth = Base64.getEncoder().encodeToString(("api:" + settings.requiredSecret()).getBytes(StandardCharsets.UTF_8));
String host = "EU".equals(region) ? "https://api.eu.mailgun.net" : "https://api.mailgun.net";
HttpResponse<String> response = EmailHttpSupport.post(http, host + "/v3/" + domain + "/messages.mime",
"multipart/form-data; boundary=" + boundary, "Authorization", "Basic " + auth, body.toByteArray());
EmailHttpSupport.checkStatus(response, "Mailgun", 200);
String id = EmailHttpSupport.responseJson(response).path("id").textValue();
if(id != null && id.startsWith("<") && id.endsWith(">"))
{
id = id.substring(1, id.length() - 1);
}
return (EmailHttpSupport.result(id));
}
/*******************************************************************************
** Encode fixed-name UTF-8 multipart fields without touching the filesystem.
*******************************************************************************/
private static void field(ByteArrayOutputStream body, String boundary, String name, String value)
{
body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
+ value + "\r\n").getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,129 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
/*******************************************************************************
** Postmark's single-message API with per-attempt server token resolution.
*******************************************************************************/
public class PostmarkEmailTransport implements EmailTransport
{
private final EmailHttp http;
/*******************************************************************************
** Fixed production endpoint and JDK HTTP implementation.
*******************************************************************************/
public PostmarkEmailTransport()
{
this(EmailHttp::sendJdk);
}
/*******************************************************************************
** Package-visible request/response test seam.
*******************************************************************************/
PostmarkEmailTransport(EmailHttp http)
{
this.http = http;
}
/*******************************************************************************
** Send a rendered message; provider response diagnostics never escape.
*******************************************************************************/
@Override
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
{
EmailHttpSupport.validate(envelope);
Map<String, Object> body = new LinkedHashMap<>();
body.put("From", envelope.getFrom());
body.put("To", String.join(",", envelope.getTo()));
body.put("Cc", String.join(",", envelope.getCc()));
body.put("Bcc", String.join(",", envelope.getBcc()));
if(envelope.getReplyTo() != null)
{
body.put("ReplyTo", envelope.getReplyTo());
}
body.put("Subject", envelope.getSubject());
if(envelope.getHtmlBody() != null)
{
body.put("HtmlBody", envelope.getHtmlBody());
}
if(envelope.getTextBody() != null)
{
body.put("TextBody", envelope.getTextBody());
}
body.put("Headers", EmailHttpSupport.headers(envelope).entrySet().stream()
.map(h -> Map.of("Name", h.getKey(), "Value", h.getValue())).toList());
String stream = settings.value("messageStream");
body.put("MessageStream", stream == null || stream.isBlank() ? "outbound" : stream);
body.put("TrackOpens", settings.enabled("trackOpens"));
body.put("TrackLinks", settings.enabled("trackClicks") ? "HtmlAndText" : "None");
HttpResponse<String> response = EmailHttpSupport.post(http, "https://api.postmarkapp.com/email", "application/json",
"X-Postmark-Server-Token", settings.requiredSecret(), EmailHttpSupport.json(body));
int status = response.statusCode();
if(status == 408 || status == 429 || status >= 500 || status == 401 || status == 403 || (status >= 300 && status < 400))
{
EmailHttpSupport.checkStatus(response, "Postmark", 200);
}
JsonNode json;
try
{
json = EmailHttpSupport.responseJson(response);
}
catch(EmailTransportException e)
{
EmailHttpSupport.checkStatus(response, "Postmark", 200);
throw (e);
}
Integer code = json.path("ErrorCode").canConvertToInt() ? json.path("ErrorCode").intValue() : null;
if(status == 200 && code == null)
{
throw (new EmailTransportException("Email provider returned an invalid response", false));
}
if(status != 200 || code == null || code != 0)
{
// Postmark ErrorCode (not HTTP status) | Classification
// 10 bad token | provider-level
// 300 invalid request, 406 inactive | permanent message failure
// 400/401 sender signature, 405 send disabled, 412 pending approval,
// 413 account unapproved, 422 invalid server, 1235/1236 stream,
// 1480 IP restriction | provider-level
// HTTP 429 / 5xx | transient, takes precedence
// Other request codes | permanent (unknown 200 is transient)
boolean providerLevel = code != null && Set.of(10, 400, 401, 405, 412, 413, 422, 1235, 1236, 1480).contains(code);
throw (EmailHttpSupport.failure(response, "Postmark", code, !providerLevel && (status >= 400 || code != null && code != 0), providerLevel));
}
return (EmailHttpSupport.result(json.path("MessageID").textValue()));
}
}
@@ -0,0 +1,145 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** SendGrid v3 mail send with explicit per-message tracking settings.
*******************************************************************************/
public class SendGridEmailTransport implements EmailTransport
{
private static final Set<String> RESERVED_HEADERS = Set.of("x-sg-id", "x-sg-eid", "received", "dkim-signature",
"content-type", "content-transfer-encoding", "to", "from", "subject", "reply-to", "cc", "bcc");
private final EmailHttp http;
/*******************************************************************************
** Fixed production endpoint and JDK HTTP implementation.
*******************************************************************************/
public SendGridEmailTransport()
{
this(EmailHttp::sendJdk);
}
/*******************************************************************************
** Package-visible request/response test seam.
*******************************************************************************/
SendGridEmailTransport(EmailHttp http)
{
this.http = http;
}
/*******************************************************************************
** Build one personalization, keeping blind recipients out of custom headers.
*******************************************************************************/
@Override
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
{
EmailHttpSupport.validate(envelope);
Map<String, Object> body = new LinkedHashMap<>();
Map<String, Object> personalization = new LinkedHashMap<>();
personalization.put("to", addresses(envelope.getTo()));
if(!envelope.getCc().isEmpty())
{
personalization.put("cc", addresses(envelope.getCc()));
}
if(!envelope.getBcc().isEmpty())
{
personalization.put("bcc", addresses(envelope.getBcc()));
}
body.put("personalizations", List.of(personalization));
body.put("from", addresses(List.of(envelope.getFrom())).getFirst());
if(envelope.getReplyTo() != null)
{
body.put("reply_to", addresses(List.of(envelope.getReplyTo())).getFirst());
}
body.put("subject", envelope.getSubject());
List<Map<String, String>> content = new ArrayList<>();
if(envelope.getTextBody() != null && !envelope.getTextBody().isEmpty())
{
content.add(Map.of("type", "text/plain", "value", envelope.getTextBody()));
}
if(envelope.getHtmlBody() != null && !envelope.getHtmlBody().isEmpty())
{
content.add(Map.of("type", "text/html", "value", envelope.getHtmlBody()));
}
body.put("content", content);
Map<String, String> headers = EmailHttpSupport.headers(envelope);
headers.keySet().removeIf(name -> RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)));
body.put("headers", headers);
body.put("custom_args", Map.of("qqq_email_id", envelope.getMessageId()));
body.put("tracking_settings", Map.of("open_tracking", Map.of("enable", settings.enabled("trackOpens")),
"click_tracking", Map.of("enable", settings.enabled("trackClicks"), "enable_text", settings.enabled("trackClicks"))));
HttpResponse<String> response = EmailHttpSupport.post(http, "https://api.sendgrid.com/v3/mail/send", "application/json",
"Authorization", "Bearer " + settings.requiredSecret(), EmailHttpSupport.json(body));
EmailHttpSupport.checkStatus(response, "SendGrid", 202);
return (EmailHttpSupport.result(response.headers().firstValue("X-Message-Id").orElse(null)));
}
/*******************************************************************************
** Convert mailboxes without losing their decoded display names.
*******************************************************************************/
private static List<Map<String, String>> addresses(List<String> values) throws EmailTransportException
{
List<Map<String, String>> result = new ArrayList<>();
try
{
for(String value : values)
{
for(InternetAddress address : InternetAddress.parse(value, true))
{
address.validate();
Map<String, String> entry = new LinkedHashMap<>();
entry.put("email", address.getAddress());
if(address.getPersonal() != null)
{
entry.put("name", address.getPersonal());
}
result.add(entry);
}
}
}
catch(AddressException e)
{
throw (new EmailTransportException("Invalid email message", true));
}
return (result);
}
}
@@ -64,9 +64,9 @@ public class SesEmailTransport implements EmailTransport
}
/*******************************************************************************
** Package-visible fake-client seam.
** Injectable client factory for embedded hosts and local demonstrations.
*******************************************************************************/
SesEmailTransport(Function<SesV2ClientBuilder, SesV2Client> clientFactory)
public SesEmailTransport(Function<SesV2ClientBuilder, SesV2Client> clientFactory)
{
this.clientFactory = clientFactory;
}
@@ -48,6 +48,12 @@ public class SmtpEmailTransport implements EmailTransport
{
throw (new EmailTransportException("SMTP sign-in needs STARTTLS or TLS", false).withProviderLevel(true));
}
if("GMAIL_SMTP".equals(settings.value("kind"))
&& (settings.value("usernameEnvName") == null || settings.value("secretEnvName") == null
|| (settings.value("security") != null && !"STARTTLS".equals(settings.value("security")))))
{
throw (new EmailTransportException("Gmail SMTP needs both credential names and STARTTLS", false).withProviderLevel(true));
}
Properties properties = new Properties();
properties.setProperty("mail.smtp.connectiontimeout", "10000");
properties.setProperty("mail.smtp.timeout", "15000");
@@ -0,0 +1,292 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.webhook;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.tracking.EmailTrackingStore;
import com.kingsrook.qbits.email.tracking.EmailUnsubscribe;
import com.kingsrook.qbits.email.tracking.MailgunVerifier;
import com.kingsrook.qbits.email.tracking.PostmarkVerifier;
import com.kingsrook.qbits.email.tracking.SendGridVerifier;
import com.kingsrook.qbits.email.tracking.SnsVerifier;
import com.kingsrook.qbits.email.tracking.WebhookVerification;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Authenticated provider ingress and signed public opt-out outside the UI API.
** No bodies, tokens, addresses or exception diagnostics are logged here.
*******************************************************************************/
public class EmailWebhookRouteProvider implements QJavalinRouteProviderInterface
{
private static final int MAX_BODY = 1024 * 1024;
private QInstance instance;
private final SnsVerifier sns;
/*******************************************************************************
** Middleware constructs the default route provider.
*******************************************************************************/
public EmailWebhookRouteProvider()
{
this(new SnsVerifier());
}
/*******************************************************************************
** Inject the existing HTTP seam through the SNS verifier for local tests.
*******************************************************************************/
public EmailWebhookRouteProvider(SnsVerifier sns)
{
this.sns = sns;
}
/*******************************************************************************
** Idempotent registration even when a producer is run repeatedly.
*******************************************************************************/
public static void register(QInstance instance)
{
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
if(CollectionUtils.nonNullList(metadata.getAdditionalRouteProviderReferences()).stream()
.noneMatch(reference -> reference != null && EmailWebhookRouteProvider.class.getName().equals(reference.getName())))
{
metadata.withAdditionalRouteProviderReference(new QCodeReference(EmailWebhookRouteProvider.class));
}
}
/*******************************************************************************
** Supplied by the host middleware.
*******************************************************************************/
@Override
public void setQInstance(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** Specific unsubscribe routes precede the provider wildcard route.
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
String base = EmailRuntime.config(instance).getWebhookBasePath();
config.routes.get(base + "/unsubscribe/{token}", this::unsubscribe);
config.routes.post(base + "/unsubscribe/{token}", this::unsubscribe);
config.routes.post(base + "/{kind}/{providerName}", this::receive);
}
/*******************************************************************************
** Bound streaming input even when Content-Length is absent or dishonest.
*******************************************************************************/
private static byte[] body(Context context) throws java.io.IOException
{
if(context.req().getContentLengthLong() > MAX_BODY)
{
respond(context, 413, "error", "Payload too large");
return (null);
}
byte[] bytes = context.req().getInputStream().readNBytes(MAX_BODY + 1);
if(bytes.length > MAX_BODY)
{
respond(context, 413, "error", "Payload too large");
return (null);
}
return (bytes);
}
/*******************************************************************************
** Authenticate before storing. Missing configuration and DB outages are retryable.
*******************************************************************************/
private void receive(Context context)
{
CapturedContext previous = QContext.capture();
try
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
byte[] bytes = body(context);
if(bytes == null)
{
return;
}
String kind = context.pathParam("kind").toUpperCase(Locale.ROOT);
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, EmailStore.equal("name", context.pathParam("providerName")));
if(providers.size() != 1 || !Boolean.TRUE.equals(providers.getFirst().getValueBoolean("enabled"))
|| !kind.equals(providers.getFirst().getValueString("kind")) || !List.of("SES", "POSTMARK", "SENDGRID", "MAILGUN").contains(kind))
{
respond(context, 404, "error", "Provider not found");
return;
}
QRecord provider = providers.getFirst();
String secret = "SES".equals(kind) ? provider.getValueString("snsTopicArn") : WebhookVerification.secret(provider);
if(secret == null || secret.isBlank())
{
respond(context, 503, "error", "Webhook verification unavailable");
return;
}
String payload = new String(bytes, StandardCharsets.UTF_8);
JSONObject json = "SENDGRID".equals(kind) ? new JSONObject() : new JSONObject(payload);
boolean verified = switch(kind)
{
case "SES" -> sns.verify(json, secret, EmailRuntime.clock().instant());
case "POSTMARK" -> new PostmarkVerifier().verify(context.header("Authorization"), secret);
case "SENDGRID" -> new SendGridVerifier().verify(bytes, context.header("X-Twilio-Email-Event-Webhook-Signature"),
context.header("X-Twilio-Email-Event-Webhook-Timestamp"), secret, EmailRuntime.clock().instant());
case "MAILGUN" -> new MailgunVerifier().verify(json, secret, EmailRuntime.clock().instant());
default -> false;
};
if(!verified)
{
respond(context, 401, "error", "Webhook verification failed");
return;
}
String id = WebhookVerification.hash(bytes);
String nonce = null;
String note = null;
if("SES".equals(kind))
{
id = json.getString("MessageId");
if(!"Notification".equals(json.getString("Type")))
{
if(!SnsVerifier.allowedUrl(json.getString("SubscribeURL"), false))
{
respond(context, 401, "error", "Webhook verification failed");
return;
}
if("SubscriptionConfirmation".equals(json.getString("Type")))
{
sns.confirm(json);
}
note = "SNS subscription control message verified";
}
}
else if("MAILGUN".equals(kind))
{
id = json.getJSONObject("event-data").getString("id");
nonce = json.getJSONObject("signature").getString("token");
}
else if("POSTMARK".equals(kind) && json.has("ID"))
{
id = json.getString("RecordType") + ":" + json.get("ID");
}
else if("SENDGRID".equals(kind))
{
new JSONArray(payload);
}
if(id.isBlank() || id.length() > 500)
{
respond(context, 401, "error", "Webhook verification failed");
return;
}
EmailTrackingStore.Receipt outcome = EmailTrackingStore.receive(provider, id, nonce, bytes, note);
respond(context, outcome == EmailTrackingStore.Receipt.REPLAY ? 401 : 200,
outcome == EmailTrackingStore.Receipt.REPLAY ? "error" : outcome == EmailTrackingStore.Receipt.DUPLICATE ? "duplicate" : "received", true);
}
catch(org.json.JSONException e)
{
respond(context, 401, "error", "Webhook verification failed");
}
catch(Exception e)
{
if(e instanceof InterruptedException)
{
Thread.currentThread().interrupt();
}
respond(context, 503, "error", "Email inbox unavailable");
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** GET is read-only. Only an explicit one-click or page form POST opts out.
*******************************************************************************/
private void unsubscribe(Context context)
{
CapturedContext previous = QContext.capture();
try
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer")
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'");
byte[] bytes = body(context);
if(bytes == null)
{
return;
}
String token = context.pathParam("token");
EmailUnsubscribe service = new EmailUnsubscribe();
QRecord message = service.resolve(token);
if(message == null)
{
context.status(404).contentType("text/html; charset=utf-8").result("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>Link not found</title><p>This link is unavailable.</p></html>");
return;
}
boolean post = "POST".equals(context.req().getMethod());
boolean oneClick = false;
if(post)
{
String form = new String(bytes, StandardCharsets.UTF_8);
oneClick = "List-Unsubscribe=One-Click".equals(form);
if(!oneClick && !"confirm=unsubscribe".equals(form))
{
respond(context, 400, "error", "Unsubscribe confirmation required");
return;
}
service.unsubscribe(token);
}
context.status(200).contentType(oneClick ? "text/plain" : "text/html; charset=utf-8").result(oneClick ? "" : service.page(message, post));
}
catch(Exception e)
{
respond(context, 503, "error", "Unsubscribe unavailable");
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** Fixed JSON responses contain no request or credential data.
*******************************************************************************/
private static void respond(Context context, int status, String key, Object value)
{
context.status(status).contentType("application/json").result(new JSONObject().put(key, value).toString());
}
}
@@ -498,13 +498,13 @@ class EmailDeliveryTest
})
.sendNow(queued.messageId()))
.isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
SystemWrites.updateOne(
EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("kind", "POSTMARK"));
var future = EmailService.send(request());
SystemWrites.updateOne(
EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("kind", "GMAIL_API"));
assertThat(new EmailDispatcher().sendNow(future.messageId()))
.isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
assertThatThrownBy(() -> new EmailTransportRegistry().get("POSTMARK"))
.hasMessageContaining("phase (b)");
assertThatThrownBy(() -> new EmailTransportRegistry().get("GMAIL_API"))
.hasMessage("Gmail API sending is specified but not built; use GMAIL_SMTP");
}
/*******************************************************************************
@@ -544,6 +544,8 @@ class EmailDeliveryTest
@Test
void suppressionScopeCategoryExpiryAndPartial() throws Exception
{
EmailRuntime.config().withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "test-unsubscribe-key");
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "postal").withValue("isDefault", true).withValue("postalAddress", "123 Test Street"));
H2Support.insert(
EmailTables.SUPPRESSION,
new QRecord()
@@ -581,6 +583,48 @@ class EmailDeliveryTest
.isEqualTo(EmailSendResult.Status.QUEUED);
}
/*******************************************************************************
** Opt-out messages are one mailbox per send, before suppression can trim them.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"NOTIFICATION", "MARKETING"})
void unsubscribeRequiresOneToRecipient(String category) throws Exception
{
EmailRuntime.config().withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "test-unsubscribe-key");
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "postal").withValue("isDefault", true).withValue("postalAddress", "123 Test Street"));
for(EmailRequest invalid : List.of(request().withTo(List.of("person@example.com", "other@example.com")),
request().withTo("person@example.com, other@example.com"), request().withCc("other@example.com"),
request().withBcc("other@example.com"), request().withTo(List.of()).withCc("other@example.com"),
request().withTo(List.of()).withBcc("other@example.com"), request().withTo(List.of()), request().withCc("person@example.com")))
{
var result = EmailService.send(invalid.withCategory(EmailCategory.valueOf(category)));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(result.reasons()).containsExactly("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient.");
}
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "other@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"));
assertThat(EmailService.send(request().withCc("other@example.com").withCategory(EmailCategory.valueOf(category))).status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** Transactional mail and notification mail without opt-out retain multi-party delivery.
*******************************************************************************/
@Test
void multiRecipientWithoutUnsubscribeStillQueues() throws Exception
{
EmailRuntime.config().withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "test-unsubscribe-key");
var result = EmailService.send(request().withTo(List.of("person@example.com, other@example.com")).withCc("copy@example.com").withBcc("blind@example.com"));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
assertThat(message(result.messageId()).getValueString("unsubscribeUrl")).isNull();
assertThat(new EmailDispatcher().runOnce().sent()).isEqualTo(1);
assertThat(mail.getReceivedMessages()).hasSize(4);
assertThat(mail.getReceivedMessages()[0].getAllRecipients()).hasSize(3);
EmailRuntime.config().withSecretResolver(name -> null);
result = EmailService.send(request().withCc("copy@example.com").withBcc("blind@example.com").withCategory(EmailCategory.NOTIFICATION));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
assertThat(message(result.messageId()).getValueString("unsubscribeUrl")).isNull();
}
/*******************************************************************************
** Rate Limits Do Not Consume Attempts And Send After Is Honored.
*******************************************************************************/
@@ -0,0 +1,262 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.email.app.EmailHome;
import com.kingsrook.qbits.email.metadata.EmailEnums;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.ContrastCheck;
import com.kingsrook.qbits.email.render.EmailLayout;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Home data and rendered output, real record labels, and branded preview contrast.
*******************************************************************************/
class EmailHomePolishTest
{
private static final Instant NOW = Instant.parse("2026-10-14T02:12:00Z");
/*******************************************************************************
** Use a fixed UTC clock and isolated database for exact assertions.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(c -> { });
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
H2Support.provider(25);
}
/*******************************************************************************
** Clear session and clock.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** Exact counts, plain labels, large number first, and failures alone are red.
*******************************************************************************/
@Test
void tilesAndOrderedSections() throws Exception
{
SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("status", "DEAD_LETTER")
.withValue("sentDate", NOW.minusSeconds(86400)).withValue("deliveredDate", NOW.plusSeconds(1))
.withValue("deadLetterDate", NOW).withValue("bouncedDate", NOW.minusSeconds(7 * 86400))
.withValue("complainedDate", NOW.minusSeconds(7 * 86400 + 1)));
for(String type : List.of("DELIVERED", "BOUNCED", "COMPLAINED"))
{
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", type).withValue("eventKey", type)
.withValue("occurredDate", "DELIVERED".equals(type) ? NOW.plusSeconds(1)
: NOW.minusSeconds(7 * 86400 + ("COMPLAINED".equals(type) ? 1 : 0))));
}
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "a@example.test").withValue("scope", "ALL").withValue("reason", "MANUAL"));
var document = Jsoup.parse(EmailHome.overview().getHtml());
var tiles = document.select("body > div > div > div");
assertThat(tiles).extracting(Element::text).containsExactly("1 Sent (24 h)", "0 Delivered (24 h)", "1 Dead letters (24 h)", "1 Bounced (7 d)", "0 Complaints (7 d)", "1 Suppressed addresses");
for(int i = 0; i < tiles.size(); i++)
{
assertThat(tiles.get(i).child(0).attr("style")).contains("font-size:1.6rem");
assertThat(tiles.get(i).child(0).attr("style").contains("#d64545")).isEqualTo(i == 2 || i == 3);
}
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
}
/*******************************************************************************
** Every attempt outcome and suppression reason feeds its actual UTC bucket.
*******************************************************************************/
@Test
void stackedBarsAndCompactAxes() throws Exception
{
int number = 0;
for(String outcome : List.of("SUCCEEDED", "TRANSIENT_FAILURE", "PROVIDER_FAILURE", "PERMANENT_FAILURE", "LEASE_EXPIRED", "IN_FLIGHT"))
{
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", 1).withValue("attemptNumber", ++number)
.withValue("outcome", outcome).withValue("endDate", NOW));
}
for(var reason : EmailEnums.SuppressionReason.values())
{
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", reason.name() + "@example.test").withValue("scope", "ALL").withValue("reason", reason.name()));
}
var hourly = EmailHome.hourly().getChartData();
assertThat(hourly.getLabels()).hasSize(24).allMatch(s -> s.matches("[0-2][0-9]:00"));
assertThat(hourly.getLabels().getLast()).isEqualTo("02:00");
assertThat(hourly.getDatasets().get(0).getData().getLast()).isEqualTo(1);
assertThat(hourly.getDatasets().get(1).getData().getLast()).isEqualTo(4);
var suppressions = EmailHome.suppressions().getChartData();
assertThat(suppressions.getLabels()).hasSize(30).endsWith("Oct 14");
assertThat(suppressions.getDatasets()).extracting(d -> d.getLabel()).containsExactly("Hard bounce", "Complaint", "Unsubscribe", "Manual");
assertThat(suppressions.getDatasets()).allSatisfy(d -> assertThat(d.getData().getLast()).isEqualTo(1));
String html = EmailHome.overview().getHtml();
assertThat(html).contains("flex-direction:column-reverse", "02:00; Sent: 1; Failed: 4", "Oct 14; Hard bounce: 1; Complaint: 1; Unsubscribe: 1; Manual: 1")
.doesNotContain("<canvas", "<svg", "<style", "overflow:auto", "HARD_BOUNCE", "2026-10-");
assertThat(Jsoup.parse(html).select("div[role=img] > div")).hasSize(54).allSatisfy(e -> assertThat(e.attr("style")).contains("flex:1 1 0;min-width:0"));
}
/*******************************************************************************
** Twenty newest linked rows wrap long text, mask addresses, and hide PHI.
*******************************************************************************/
@Test
void recentRowsAndProviderHealth() throws Exception
{
for(int i = 0; i < 21; i++)
{
SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("status", i == 20 ? "RETRY_WAIT" : "SENT")
.withValue("subject", i == 19 ? "private-canary" : "Subject <" + i + ">" + "x".repeat(150))
.withValue("phiApproved", i == 19).withValue("toAddresses", "Person <person@" + "x".repeat(100) + ".test>"));
}
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("lastSuccessDate", NOW)
.withValue("healthStatus", "UNHEALTHY").withValue("lastFailureMessage", "<broken>").withValue("maxPerMinute", 2));
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", 1).withValue("attemptNumber", 1).withValue("providerId", 1).withValue("startDate", NOW));
String html = EmailHome.overview().getHtml();
var document = Jsoup.parse(html);
var rows = document.select("a[href^=/app/email_message/]");
assertThat(rows).hasSize(20);
assertThat(rows.first().attr("href")).isEqualTo("/app/email_message/21");
assertThat(rows.last().attr("href")).isEqualTo("/app/email_message/2");
assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry");
assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "&lt;broken&gt;", "min-width:0", "overflow-wrap:anywhere")
.doesNotContain("private-canary", "Person &lt;", "RETRY_WAIT", "2026-10-14T");
assertThat(document.select("section").get(1).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null));
assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit");
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(EmailHome.overview().getHtml()).contains("Provider health needs email.admin").doesNotContain("&lt;broken&gt;");
}
/*******************************************************************************
** Query, get and possible-value labels use the same human-readable titles.
*******************************************************************************/
@Test
void recordLabels() throws Exception
{
QRecord version = EmailTemplatesTest.draft("welcome", "MARKDOWN", "[]", "{}", "Hello");
assertThat(get(EmailTables.VERSION, version.getValueInteger("id")).getRecordLabel()).isEqualTo("welcome v1");
SystemWrites.updateOne(EmailTables.VERSION, new QRecord().withValue("id", version.getValue("id")).withValue("locale", "fr"));
assertThat(get(EmailTables.VERSION, version.getValueInteger("id")).getRecordLabel()).isEqualTo("welcome v1 (fr)");
assertThat(get(EmailTables.PROVIDER, 1).getRecordLabel()).isEqualTo("Relay");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("label", ""));
assertThat(get(EmailTables.PROVIDER, 1).getRecordLabel()).isEqualTo("relay");
for(String subject : List.of("A useful subject", "", "enc:v1:private"))
{
SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("subject", subject));
}
assertThat(get(EmailTables.MESSAGE, 1).getRecordLabel()).isEqualTo("A useful subject");
assertThat(get(EmailTables.MESSAGE, 2).getRecordLabel()).isEqualTo("Message 2");
assertThat(get(EmailTables.MESSAGE, 3).getRecordLabel()).isEqualTo("Secure message");
var queried = new QueryAction().execute(new QueryInput(EmailTables.MESSAGE).withShouldGenerateDisplayValues(true)).getRecords();
assertThat(queried).extracting(QRecord::getRecordLabel).containsExactly("A useful subject", "Message 2", "Secure message");
var possible = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(EmailTables.VERSION).withSearchTerm("fr"));
assertThat(possible.getResults()).extracting(v -> v.getLabel()).contains("welcome v1 (fr)");
assertThat(EmailEnums.MessageStatus.RETRY_WAIT.getPossibleValueLabel()).isEqualTo("Waiting to retry");
assertThat(EmailEnums.MessageStatus.DEAD_LETTER.getPossibleValueLabel()).isEqualTo("Dead letter");
}
/*******************************************************************************
** The demo brand's actual inline preview matches the email dark media query.
*******************************************************************************/
@Test
void demoPreviewButtonContrast() throws Exception
{
QRecord brand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "Kof22").withValue("label", "Kof22")
.withValue("primaryColor", "#2459a6").withValue("buttonTextColor", "#ffffff")
.withValue("textColor", "#172033").withValue("backgroundColor", "#ffffff")
.withValue("darkPrimaryColor", "#93c5fd").withValue("darkButtonTextColor", "#172033")
.withValue("darkBackgroundColor", "#111827").withValue("darkTextColor", "#f9fafb").withValue("isDefault", true));
QRecord version = EmailTemplatesTest.draft("preview", "MARKDOWN", "[]", "{}", "[Visit Kof22](https://example.test){.button}");
String html = ((RawHTML) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("emailTemplatePreview"))
.withUrlParams(Map.of("id", version.getValueString("id")))).getWidgetData()).getHtml();
var document = Jsoup.parse(html);
Element dark = document.select("h4").stream().filter(e -> e.text().startsWith("Dark")).findFirst().orElseThrow().nextElementSibling();
Element button = dark.select("a[href=https://example.test]").first();
assertThat(button).isNotNull();
String foreground = css(button, "color");
String background = css(button.parent(), "background-color");
assertThat(foreground).isEqualTo("#172033");
assertThat(background).isEqualTo("#93c5fd");
assertThat(ContrastCheck.ratio(foreground, background)).isGreaterThanOrEqualTo(4.5);
assertThat(dark.attr("style")).contains("background-color:#111827", "color:#f9fafb");
assertThat(EmailLayout.render("Body", "", brand, EmailCategory.TRANSACTIONAL, null))
.contains(".email-button{background-color:" + background + "!important}", ".email-button a{color:" + foreground + "!important}");
}
/*******************************************************************************
** Missing dark button text chooses an accessible colour for either primary.
*******************************************************************************/
@Test
void accessibleDarkButtonFallback()
{
for(String primary : List.of("#93c5fd", "#172033", "#777777"))
{
QRecord brand = new QRecord().withValue("darkPrimaryColor", primary);
assertThat(ContrastCheck.ratio(ContrastCheck.color(brand, "darkButtonTextColor"), primary)).isGreaterThanOrEqualTo(4.5);
ContrastCheck.validate(brand);
}
assertThat(ContrastCheck.color(new QRecord().withValue("darkPrimaryColor", "#93c5fd").withValue("darkButtonTextColor", "#172033"), "darkButtonTextColor")).isEqualTo("#172033");
}
/*******************************************************************************
** Exercise real get/display-value actions, not just metadata declarations.
*******************************************************************************/
private static QRecord get(String table, int id) throws Exception
{
return (new GetAction().execute(new GetInput(table).withPrimaryKey(id).withShouldGenerateDisplayValues(true)).getRecord());
}
/*******************************************************************************
** Read the rendered inline pair; remove CSS priority before computing ratio.
*******************************************************************************/
private static String css(Element element, String property)
{
return (List.of(element.attr("style").split(";")).stream().map(String::trim).filter(s -> s.startsWith(property + ":"))
.findFirst().orElseThrow().substring(property.length() + 1).replace("!important", "").trim());
}
}
@@ -73,7 +73,7 @@ class EmailMetadataTest
void producerStandardsAndIntegrationFields() throws Exception
{
var instance = QContext.getQInstance();
assertThat(instance.getTables()).hasSize(7);
assertThat(instance.getTables()).hasSize(9);
for(var table : instance.getTables().values())
{
assertThat(table.getName()).matches("email_[a-z_]+");
@@ -105,7 +105,7 @@ class EmailMetadataTest
assertThat(
new EmailApp.HomeRenderer()
.render(
new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()))
new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput().withWidgetMetaData(instance.getWidget(EmailApp.HOME_WIDGET))))
.isNotNull();
assertThat(EmailRuntime.isInstalled()).isTrue();
assertThat(EmailRuntime.config(new QInstance())).isNull();
@@ -215,7 +215,7 @@ class EmailMetadataTest
.withSubject("s")
.withMarkdownBody("<script>alert(1)</script>\n\n**Hello**");
assertThat(EmailService.send(new EmailRequest().withTemplateName("later")).reasons())
.contains("templates arrive in part 2");
.contains("Unknown email template");
assertThat(EmailService.send(null).status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(EmailService.send(new EmailRequest()).status())
.isEqualTo(EmailSendResult.Status.REFUSED);
@@ -307,7 +307,7 @@ class EmailMetadataTest
{
var config = EmailRuntime.config();
assertThat(config.getAllowPlaintextSmtpAuth()).isFalse();
for(String kind : List.of("SMTP", "GMAIL_SMTP"))
for(String kind : List.of("SMTP"))
{
for(String credential : List.of("usernameEnvName", "secretEnvName"))
{
@@ -495,8 +495,8 @@ class EmailMetadataTest
config.validate(QContext.getQInstance(), errors);
assertThat(errors).isEmpty();
var scheduled = EmailProcesses.processes(config).stream().filter(p -> p.getSchedule() != null).toList();
assertThat(scheduled).hasSize(3).allMatch(p -> p.getIsHidden());
assertThat(scheduled).extracting(p -> p.getSchedule().getRepeatSeconds()).containsExactly(15, 300, 86400);
assertThat(scheduled).hasSize(4).allMatch(p -> p.getIsHidden());
assertThat(scheduled).extracting(p -> p.getSchedule().getRepeatSeconds()).containsExactly(30, 15, 300, 86400);
}
}
@@ -0,0 +1,199 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.time.Clock;
import java.time.Duration;
import java.time.ZoneOffset;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qbits.email.tracking.EmailUnsubscribe;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.NOW;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.SECRET;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Retention of opt-out mail and the consumer-facing template API.
*******************************************************************************/
class EmailPhaseCReviewTest
{
/*******************************************************************************
** Isolated database with opt-out links configured and message retention on.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(c -> c.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> SECRET).withMessageRetentionDays(30));
H2Support.provider(25);
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
}
/*******************************************************************************
** Clear thread and clock state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** An old final message with content, in the given category.
*******************************************************************************/
private static QRecord oldMessage(String category, String address) throws Exception
{
return (SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("providerId", H2Support.all(EmailTables.PROVIDER).getFirst().getValue("id"))
.withValue("status", "SENT").withValue("category", category)
.withValue("toAddresses", address).withValue("sentDate", NOW.minus(Duration.ofDays(100))).withValue("htmlBody", "<p>hi</p>")
.withValue("textBody", "hi").withValue("variablesSnapshot", "{\"name\":\"x\"}")));
}
/*******************************************************************************
** Old marketing and notification mail is kept (content cleared) so its
** unsubscribe link works; transactional mail is deleted.
*******************************************************************************/
@Test
void retentionKeepsOptOutMessagesAndTheirUnsubscribeLinks() throws Exception
{
QRecord marketing = oldMessage("MARKETING", "pat@example.com");
QRecord notification = oldMessage("NOTIFICATION", "sam@example.com");
QRecord transactional = oldMessage("TRANSACTIONAL", "lee@example.com");
String url = EmailUnsubscribeLinks.create(marketing.getValueString("id"), "pat@example.com", "MARKETING");
assertThat(url).isNotNull();
EmailProcesses.retain();
assertThat(EmailStore.get(EmailTables.MESSAGE, transactional.getValueInteger("id"), null)).isNull();
for(QRecord kept : List.of(marketing, notification))
{
QRecord row = EmailStore.get(EmailTables.MESSAGE, kept.getValueInteger("id"), null);
assertThat(row).isNotNull();
assertThat(row.getValueString("htmlBody")).isNull();
assertThat(row.getValueString("textBody")).isNull();
assertThat(row.getValueString("variablesSnapshot")).isNull();
}
new EmailUnsubscribe().unsubscribe(url.substring(url.lastIndexOf('/') + 1));
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1).allSatisfy(row ->
{
assertThat(row.getValueString("address")).isEqualTo("pat@example.com");
assertThat(row.getValueString("scope")).isEqualTo("MARKETING");
});
EmailProcesses.retain();
assertThat(EmailStore.get(EmailTables.MESSAGE, marketing.getValueInteger("id"), null)).isNotNull();
}
/*******************************************************************************
** A complete definition used by the template tests.
*******************************************************************************/
private static EmailTemplateDefinition definition()
{
return (new EmailTemplateDefinition().withName("order_ready").withLabel("Order ready").withCategory(EmailCategory.TRANSACTIONAL)
.withSubject("Order {{order_number}} is ready").withBody("Hi {{customer_name}}, order {{order_number}} is ready.")
.withVariable("order_number", "Order number", "STRING", true, "PUBLIC")
.withVariable("customer_name", "Customer name", "STRING", true, null)
.withSampleVariables("{\"order_number\":\"A-1\",\"customer_name\":\"Pat\"}").withOwnerQbit("orders"));
}
/*******************************************************************************
** Created once as an ACTIVE version 1 that sends; the second call changes nothing.
*******************************************************************************/
@Test
void ensureCreatesOnceAndIsIdempotent() throws Exception
{
assertThat(EmailTemplates.ensure(definition())).isTrue();
assertThat(EmailTemplates.ensure(definition())).isFalse();
QRecord template = H2Support.all(EmailTables.TEMPLATE).getFirst();
assertThat(H2Support.all(EmailTables.TEMPLATE)).hasSize(1);
assertThat(template.getValueString("ownerQbit")).isEqualTo("orders");
assertThat(template.getValueString("kind")).isEqualTo("MARKDOWN");
assertThat(template.getValueBoolean("phiApproved")).isFalse();
List<QRecord> versions = H2Support.all(EmailTables.VERSION);
assertThat(versions).hasSize(1);
assertThat(versions.getFirst().getValueString("status")).isEqualTo("ACTIVE");
assertThat(versions.getFirst().getValueInteger("versionNumber")).isEqualTo(1);
assertThat(versions.getFirst().getValueString("variablesSchema")).contains("order_number").contains("PUBLIC");
assertThat(template.getValue("activeVersionId")).isEqualTo(versions.getFirst().getValue("id"));
EmailSendResult result = EmailService.send(new EmailRequest().withTemplateName("order_ready").withTo("pat@example.com")
.withVariable("order_number", "A-1").withVariable("customer_name", "Pat"));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
}
/*******************************************************************************
** An edited template keeps its edits when the defining qbit calls ensure again.
*******************************************************************************/
@Test
void ensureDoesNotTouchAnEditedTemplate() throws Exception
{
assertThat(EmailTemplates.ensure(definition())).isTrue();
QRecord template = H2Support.all(EmailTables.TEMPLATE).getFirst();
QRecord draft = EmailVersions.newVersion(template.getValueInteger("id"));
new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(new QRecord().withValue("id", draft.getValue("id"))
.withValue("bodySource", "Edited by a person for {{order_number}}"))));
EmailVersions.activate(draft.getValueInteger("id"));
assertThat(EmailTemplates.ensure(definition().withBody("Changed again {{order_number}} {{customer_name}}"))).isFalse();
assertThat(H2Support.all(EmailTables.VERSION)).hasSize(2);
QRecord active = EmailStore.get(EmailTables.VERSION, EmailStore.get(EmailTables.TEMPLATE, template.getValueInteger("id"), null).getValueInteger("activeVersionId"), null);
assertThat(active.getValueInteger("versionNumber")).isEqualTo(2);
assertThat(active.getValueString("bodySource")).isEqualTo("Edited by a person for {{order_number}}");
}
/*******************************************************************************
** Invalid definitions are refused with plain reasons and write nothing.
*******************************************************************************/
@Test
void ensureRefusesInvalidDefinitions() throws Exception
{
assertThatThrownBy(() -> EmailTemplates.ensure(new EmailTemplateDefinition().withName("Bad Name").withKind("PDF")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Name must be snake_case").hasMessageContaining("Label is required").hasMessageContaining("Category is required")
.hasMessageContaining("Kind must be").hasMessageContaining("Subject is required").hasMessageContaining("Body is required");
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withVariablesSchema("[]")))
.hasMessageContaining("not both");
assertThatThrownBy(() -> EmailTemplates.ensure(new EmailTemplateDefinition().withName("x").withLabel("X").withCategory(EmailCategory.SECURITY)
.withSubject("S").withBody("B").withVariablesSchema("{").withSampleVariables("[")))
.hasMessageContaining("Invalid variables schema").hasMessageContaining("Variables must be a JSON object");
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withBody("Hi {{undeclared_thing}}")))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("refused");
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withSampleVariables("{}")))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("refused");
assertThat(H2Support.all(EmailTables.TEMPLATE)).isEmpty();
assertThat(H2Support.all(EmailTables.VERSION)).isEmpty();
}
}
@@ -146,9 +146,11 @@ class EmailProcessesTest
@Test
void backendSendAndScheduledJobs() throws Exception
{
assertThat(QContext.getQInstance().getProcess(EmailProcesses.SEND_TEMPLATED).getBackendStep("send").getInputMetaData().getField("category")).isEmpty();
assertThat(QContext.getQInstance().getProcess(EmailProcesses.SEND_AD_HOC).getBackendStep("send").getInputMetaData().getField("category")).isPresent();
var queued =
run(
EmailProcesses.SEND_TEMPLATED,
EmailProcesses.SEND_AD_HOC,
Map.of(
"to",
"a@example.com",
@@ -159,6 +161,7 @@ class EmailProcessesTest
"category",
"NOTIFICATION"));
assertThat(queued.getValues().get("status")).isEqualTo("QUEUED");
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("category")).isEqualTo("NOTIFICATION");
run(EmailProcesses.DISPATCH, Map.of());
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
run(EmailProcesses.MAINTAIN, Map.of());
@@ -0,0 +1,175 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.render.ContrastCheck;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.render.EmailLayout;
import com.kingsrook.qbits.email.render.EmailMarkdown;
import com.kingsrook.qbits.email.render.HtmlToText;
import com.kingsrook.qbits.email.template.CliMjmlCompiler;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Template grammar, content boundaries, accessible layout and validation.
*******************************************************************************/
class EmailRenderingTest
{
private final EmailTemplateEngine engine = new EmailTemplateEngine();
private final VariableSchema schema = new VariableSchema("[{\"name\":\"name\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"record\",\"type\":\"RECORD\"}]");
/*******************************************************************************
** Escaping belongs to the output context, and fallbacks are escaped too.
*******************************************************************************/
@Test
void escapingAndFallbacks()
{
String attack = "<img src=x onerror='bad()'> & \"test\"";
var parsed = engine.parse("{{name}} {{record.name|<fallback>}}", schema);
assertThat(parsed.render(Map.of("name", attack), EmailTemplateEngine.Context.HTML)).contains("&lt;img", "&#39;", "&quot;", "&lt;fallback&gt;").doesNotContain("<img");
assertThat(parsed.render(Map.of("name", attack), EmailTemplateEngine.Context.TEXT)).contains(attack);
String markdown = engine.parse("{{name}}", schema).render(Map.of("name", "[x](javascript:alert(1))\n# heading <script>x</script>"), EmailTemplateEngine.Context.MARKDOWN);
String html = EmailMarkdown.render(markdown, new QRecord());
assertThat(html).doesNotContain("<a", "<h1", "<script").contains("javascript:alert(1)");
}
/*******************************************************************************
** Nested conditionals, loops, dotted values, indices and restored scope.
*******************************************************************************/
@Test
void blocksAndTruthiness()
{
var parsed = engine.parse("{{#if name}}Hello {{name}}{{/if}}{{#each items}}[{{@index}}:{{this.field|empty}}{{#if this.ok}}!{{/if}}]{{/each}} {{record.name}}", schema);
assertThat(parsed.render(Map.of("name", "Pat", "items", List.of(Map.of("field", "A", "ok", true), Map.of()), "record", Map.of("name", "R")), EmailTemplateEngine.Context.TEXT))
.isEqualTo("Hello Pat[0:A!][1:empty] R");
assertThat(engine.parse("{{#each items}}{{this}}{{/each}}", schema).render(Map.of("items", List.of("a", "b")), EmailTemplateEngine.Context.HTML)).isEqualTo("ab");
for(Object falsy : List.of(false, "", List.of(), Map.of(), 0, new BigDecimal("0.00")))
{
assertThat(EmailTemplateEngine.truthy(falsy)).isFalse();
}
assertThat(EmailTemplateEngine.truthy(null)).isFalse();
assertThat(EmailTemplateEngine.truthy(-1)).isTrue();
assertThat(EmailTemplateEngine.truthy("false")).isTrue();
assertThat(engine.parse("literal", schema).render(Map.of(), EmailTemplateEngine.Context.HTML)).isEqualTo("literal");
}
/*******************************************************************************
** Authors get precise source-line diagnostics for unsupported syntax.
*******************************************************************************/
@Test
void parseErrors()
{
assertThatThrownBy(() -> engine.parse("line\n{{missing}}", schema)).hasMessageContaining("Line 2").hasMessageContaining("missing");
assertThatThrownBy(() -> engine.parse("\n{{#if name}}", schema)).hasMessageContaining("Line 2: Unclosed");
for(String source : List.of("{{#if name}}{{/each}}", "{{/if}}", "{{this}}", "{{@index}}", "{{{name}}}", "{{> partial}}", "{{name", "{{#if name|fallback}}"))
{
assertThatThrownBy(() -> engine.parse(source, schema)).isInstanceOf(IllegalArgumentException.class);
}
}
/*******************************************************************************
** Schema coercion is explicit, including strict URLs and required values.
*******************************************************************************/
@Test
void schemaValidation()
{
String json = "[{\"name\":\"n\",\"type\":\"NUMBER\",\"required\":true},{\"name\":\"d\",\"type\":\"DATE\"},{\"name\":\"t\",\"type\":\"DATE_TIME\"},{\"name\":\"b\",\"type\":\"BOOLEAN\"},{\"name\":\"url\",\"type\":\"URL\"}]";
VariableSchema schema = new VariableSchema(json);
var values = schema.validate(VariableSchema.values("{\"n\":\"1.20\",\"d\":\"2026-10-13\",\"t\":\"2026-10-13T12:00:00Z\",\"b\":\"true\",\"url\":\"https://example.com/path\"}"));
assertThat(values.get("n")).isEqualTo(new BigDecimal("1.20"));
assertThat(values.get("b")).isEqualTo(true);
assertThatThrownBy(() -> schema.validate(Map.of())).hasMessageContaining("Required variable n");
assertThatThrownBy(() -> schema.validate(Map.of("n", 1, "b", "yes"))).hasMessageContaining("BOOLEAN");
for(String url : List.of("javascript:alert(1)", "data:text/html,x", "/relative", "https://user:pass@example.com", "https://"))
{
assertThatThrownBy(() -> VariableSchema.url(url)).isInstanceOf(IllegalArgumentException.class);
}
for(String invalid : List.of("{}", "[{}]", "[{\"name\":\"x\",\"type\":\"CODE\"}]", "[{\"name\":\"x\",\"type\":\"STRING\",\"table\":\"t\"}]"))
{
assertThatThrownBy(() -> new VariableSchema(invalid)).hasMessage("Invalid variables schema");
}
assertThatThrownBy(() -> VariableSchema.values("[]")).hasMessage("Variables must be a JSON object");
assertThat(schema.knows("brand.site_url")).isTrue();
assertThat(schema.knows("brand.missing")).isFalse();
}
/*******************************************************************************
** CommonMark extensions and table buttons cannot enable raw HTML.
*******************************************************************************/
@Test
void markdownAndText()
{
String html = EmailMarkdown.render("# Heading\n\n<script>alert(1)</script>\n\n| A | B |\n|---|---|\n| One | Two |\n\n[Open](https://example.com){.button}\n\nwww.example.com\n\n- First\n- Second", new QRecord());
assertThat(html).contains("<h1>", "&lt;script&gt;", "table-layout:fixed", "email-button", "href=\"https://example.com\"", "http://www.example.com").doesNotContain("<script>", "{.button}");
String text = HtmlToText.convert(html);
assertThat(text).contains("Heading", "One", "Two", "Open (https://example.com)", "- First", "- Second");
assertThat(HtmlToText.convert("<html><head><style>bad-css</style></head><body>A &amp; B<br>C<script>bad-code</script></body></html>"))
.contains("A & B", "C").doesNotContain("bad-css", "bad-code");
}
/*******************************************************************************
** Both schemes, logo variants and category-specific footers are present.
*******************************************************************************/
@Test
void layoutAndContrast()
{
QRecord brand = new QRecord().withValue("label", "Brand").withValue("logoUrl", "https://example.com/logo.png")
.withValue("logoDarkUrl", "https://example.com/dark.png").withValue("logoAlt", "Brand logo")
.withValue("footerMarkdown", "Thanks **friend**").withValue("postalAddress", "123 Main Street")
.withValue("socialLinks", "[{\"label\":\"Social\",\"url\":\"https://example.com/social\"}]");
ContrastCheck.validate(brand);
assertThat(ContrastCheck.ratio("#000000", "#ffffff")).isEqualTo(21);
for(EmailCategory category : EmailCategory.values())
{
String html = EmailLayout.render("<p>Content</p>", "Preheader", brand, category, "https://example.com/unsubscribe");
assertThat(html).contains("prefers-color-scheme:dark", "color-scheme", "supported-color-schemes", "[data-ogsc]", "[data-ogsb]", "max-width:600px", "email-logo-dark", "Preheader", "Social").doesNotContain("background-image");
assertThat(html.contains("Unsubscribe")).isEqualTo(category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING);
assertThat(html.contains("123 Main Street")).isEqualTo(category == EmailCategory.MARKETING);
}
String appended = EmailLayout.appendFooter("<html><body>content</body></html>", brand, EmailCategory.NOTIFICATION, "https://example.com/u");
assertThat(appended.indexOf("Unsubscribe")).isLessThan(appended.indexOf("</body>"));
assertThat(EmailLayout.appendFooter("fragment", brand, EmailCategory.TRANSACTIONAL, "")).isEqualTo("fragment");
assertThatThrownBy(() -> ContrastCheck.validate(new QRecord().withValue("darkTextColor", "#555555"))).hasMessageContaining("dark mode; it needs 4.5:1");
assertThatThrownBy(() -> ContrastCheck.validate(new QRecord().withValue("primaryColor", "#ffffff"))).hasMessageContaining("Button text");
assertThatThrownBy(() -> EmailBrands.validate(new QRecord().withValue("logoUrl", "javascript:x"))).hasMessageContaining("http or https");
assertThatThrownBy(() -> EmailBrands.validate(new QRecord().withValue("textColor", "red;display:none"))).hasMessageContaining("six-digit");
assertThatThrownBy(() -> EmailLayout.footer(new QRecord(), EmailCategory.MARKETING, "")).hasMessageContaining("postal address");
}
/*******************************************************************************
** Missing or failing CLI tools return only a safe diagnostic.
*******************************************************************************/
@Test
void cliFailure()
{
assertThatThrownBy(() -> new CliMjmlCompiler("missing-mjml-binary-for-test").compile("private-source"))
.hasMessage("MJML compilation failed or exceeded 20 seconds");
}
}
@@ -0,0 +1,285 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.phi.EmailBodyEncryption;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.render.EmailMarkdown;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Regression checks for the PR review's trust and resource boundaries.
*******************************************************************************/
class EmailReviewFixesTest
{
/*******************************************************************************
** Isolated records and local test keys.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(c -> c.withSecretResolver(name -> "test-key").withPublicBaseUrl("https://example.com"));
H2Support.provider(25);
}
/*******************************************************************************
** Release request state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** Template categories cannot be downgraded, including notification suppression.
*******************************************************************************/
@Test
void categoryOverrideRefused() throws Exception
{
for(String category : List.of("MARKETING", "NOTIFICATION"))
{
QRecord version = EmailTemplatesTest.draft(category, "HTML", "[]", "{}", "<p>Hello</p>");
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("category", category));
if("MARKETING".equals(category))
{
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "brand").withValue("postalAddress", "123 Main St").withValue("isDefault", true));
}
EmailVersions.activate(version.getValueInteger("id"));
var refused = EmailService.send(new EmailRequest().withTemplateName(category).withTo("p@example.com").withCategory(EmailCategory.TRANSACTIONAL));
assertThat(refused.status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(refused.reasons()).contains("Template category cannot be overridden");
assertThat(EmailService.send(new EmailRequest().withTemplateName(category).withTo("p@example.com")).status()).isEqualTo(EmailSendResult.Status.QUEUED);
}
}
/*******************************************************************************
** Nested loops share work and byte limits, and nesting stops at eight.
*******************************************************************************/
@Test
void sharedBudgetAndNesting()
{
var engine = new EmailTemplateEngine();
var schema = new VariableSchema("[{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"value\",\"type\":\"STRING\"}]");
var loop = engine.parse("{{#each items}}{{#each items}}{{/each}}{{/each}}", schema);
assertThatThrownBy(() -> loop.render(Map.of("items", Collections.nCopies(100, "")), EmailTemplateEngine.Context.TEXT)).hasMessage("Template output is too large");
assertThatThrownBy(() -> engine.parse("{{value}}", schema).render(Map.of("value", "\u00e9".repeat(600_000)), EmailTemplateEngine.Context.TEXT))
.hasMessage("Template output is too large");
assertThat(engine.parse("{{#if value}}".repeat(8) + "ok" + "{{/if}}".repeat(8), schema).render(Map.of("value", true), EmailTemplateEngine.Context.TEXT)).isEqualTo("ok");
assertThatThrownBy(() -> engine.parse("{{#if value}}".repeat(9) + "{{/if}}".repeat(9), schema)).hasMessageContaining("8 levels");
var budget = new EmailTemplateEngine.Budget();
var parsed = engine.parse("{{value}}", schema);
parsed.render(Map.of("value", "x".repeat(600_000)), EmailTemplateEngine.Context.TEXT, budget, new ArrayList<>());
assertThatThrownBy(() -> parsed.render(Map.of("value", "x".repeat(600_000)), EmailTemplateEngine.Context.TEXT, budget, new ArrayList<>())).hasMessage("Template output is too large");
}
/*******************************************************************************
** Activation reports budgets and sends refuse rather than storing excess output.
*******************************************************************************/
@Test
void budgetAtLifecycleBoundaries() throws Exception
{
QRecord version = EmailTemplatesTest.draft("budget", "HTML", "[{\"name\":\"items\",\"type\":\"LIST\"}]", "{}", "{{#each items}}x{{/each}}");
EmailVersions.activate(version.getValueInteger("id"));
var result = EmailService.send(new EmailRequest().withTemplateName("budget").withTo("p@example.com").withVariable("items", new ArrayList<>(Collections.nCopies(10_001, "x"))));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(result.reasons()).contains("Template output is too large");
QRecord oversized = EmailTemplatesTest.draft("oversized", "HTML", "[]", "{}", "x".repeat(1_048_577));
assertThatThrownBy(() -> EmailVersions.activate(oversized.getValueInteger("id"))).hasMessage("Template output is too large");
}
/*******************************************************************************
** Unsafe URL values are omitted with warnings, while normal links still work.
*******************************************************************************/
@Test
void urlContexts()
{
var engine = new EmailTemplateEngine();
var schema = new VariableSchema("[{\"name\":\"value\",\"type\":\"STRING\"}]");
for(String attribute : List.of("href", "src", "action", "background"))
{
for(String quote : List.of("\"", "'"))
{
var parsed = engine.parse("<a " + attribute + "=" + quote + "{{value}}" + quote + ">x</a>", schema);
for(String value : List.of("javascript:alert(1)", "data:text/html,evil"))
{
List<String> warnings = new ArrayList<>();
assertThat(parsed.render(Map.of("value", value), EmailTemplateEngine.Context.HTML, new EmailTemplateEngine.Budget(), warnings)).doesNotContain(value).contains(attribute + "=" + quote + quote);
assertThat(warnings).containsExactly("Unsafe URL omitted for value");
}
assertThat(parsed.render(Map.of("value", "https://example.com"), EmailTemplateEngine.Context.HTML)).contains("https://example.com");
assertThat(parsed.render(Map.of("value", "mailto:p@example.com"), EmailTemplateEngine.Context.HTML).contains("mailto:")).isEqualTo(attribute.equals("href"));
}
}
for(String source : List.of("<a href=\" {{value}}\">x</a>", "<a href=\"{{#if value}}{{value}}{{/if}}\">x</a>", "<a href=\"java{{value}}\">x</a>"))
{
List<String> warnings = new ArrayList<>();
String html = engine.parse(source, schema).render(Map.of("value", source.contains("java{{") ? "script:alert(1)" : "javascript:alert(1)"),
EmailTemplateEngine.Context.HTML, new EmailTemplateEngine.Budget(), warnings);
assertThat(html).contains("href=\"\"").doesNotContain("javascript:");
assertThat(warnings).isNotEmpty();
}
}
/*******************************************************************************
** Refuse unquoted values and includes before a configured compiler executes.
*******************************************************************************/
@Test
void activationRejectsUnsafeSource() throws Exception
{
QRecord version = EmailTemplatesTest.draft("unquoted", "HTML", "[]", "{}", "<a href={{brand.site_url}}>Go</a>");
assertThatThrownBy(() -> EmailVersions.activate(version.getValueInteger("id"))).hasMessageContaining("must be quoted");
for(String source : List.of("<a {{brand.label}}>x</a>", "<{{brand.label}}>"))
{
assertThatThrownBy(() -> EmailTemplateEngine.validateHtml(source)).hasMessageContaining("must be quoted");
}
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Unsafe MJML reached compiler"); });
QRecord mjml = EmailTemplatesTest.draft("includes", "MJML", "[]", "{}", "<mjml><mj-include path='secret'/></mjml>");
assertThatThrownBy(() -> EmailVersions.activate(mjml.getValueInteger("id"))).hasMessage("MJML includes are not allowed");
assertThat(preview(mjml)).contains("MJML includes are not allowed");
}
/*******************************************************************************
** A viewer can inspect stored output but cannot start a compiler.
*******************************************************************************/
@Test
void viewerNeverCompiles() throws Exception
{
QRecord version = EmailTemplatesTest.draft("viewer", "MJML", "[]", "{}", "<mjml/>");
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Viewer compiled MJML"); });
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(preview(version)).contains("Not compiled yet. Activate the version or use Preview as a manager.");
QContext.getQSession().setPermissions(Set.of("email.manage"));
EmailRuntime.config().withMjmlCompiler(source -> "<p>Compiled sample</p>");
assertThat(preview(version)).contains("Compiled sample");
assertThat(EmailStore.get(EmailTables.VERSION, version.getValueInteger("id"), null).getValue("compiledHtml")).isNull();
}
/*******************************************************************************
** Approved sends retain their template brand and restrict final image hosts.
*******************************************************************************/
@Test
void approvedBrandAndImages() throws Exception
{
QRecord brand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "trusted").withValue("label", "Trusted")
.withValue("siteUrl", "https://brand.example.com").withValue("logoUrl", "https://other.example.com/logo.png"));
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "override").withValue("label", "Override"));
QRecord version = EmailTemplatesTest.draft("approved", "HTML", "[]", "{}", "<p>{{brand.label}}</p><img src='https://brand.example.com/a.png'><img src='https://cdn.example.com/b.png'><img src='https://evil.example.com/c.png'>"
+ "<!--[if mso]><img src='https://evil.example.com/outlook.png'><![endif]--><style>@IMPORT 'https://evil.example.com/pixel.css';</style>");
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("brandId", brand.getValue("id")));
EmailVersions.approve(version.getValueInteger("id"));
EmailVersions.activate(version.getValueInteger("id"));
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("phiAllowed", true));
EmailRuntime.config().withAllowedImageHosts(Set.of("cdn.example.com"));
var renderer = EmailTemplateRenderer.prepare(new EmailRequest().withTemplateName("approved").withBrandName("override"));
QRecord rendered = renderer.render("preview", null, false);
assertThat(rendered.getValue("brandId")).isEqualTo(brand.getValue("id"));
assertThat(rendered.getValueString("htmlBody")).contains("Trusted", "brand.example.com/a.png", "cdn.example.com/b.png").doesNotContain("Override", "evil.example.com", "other.example.com");
assertThat(renderer.warnings()).contains("Brand logo omitted: image host is not allowed", "Image omitted: image host is not allowed");
assertThat(EmailBrands.allowedImage("http://brand.example.com/a.png", brand)).isFalse();
assertThat(EmailBrands.allowedImage("https://brand.example.com.evil.com/a.png", brand)).isFalse();
assertThatThrownBy(() -> H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "badlogo").withValue("logoUrl", "http://example.com/logo"))).hasMessageContaining("https");
assertThatThrownBy(() -> H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "badsocial").withValue("socialLinks", "[{\"label\":\"X\",\"url\":\"http://example.com\"}]"))).hasMessageContaining("https");
}
/*******************************************************************************
** Button conversion cannot consume an earlier ordinary link.
*******************************************************************************/
@Test
void buttonMatchesOneLink()
{
String html = EmailMarkdown.render("[First](https://first.example) and [Second](https://second.example){.button}", new QRecord());
var document = org.jsoup.Jsoup.parse(html);
assertThat(document.select(".email-button a").eachAttr("href")).containsExactly("https://second.example");
assertThat(document.select("a")).hasSize(2);
}
/*******************************************************************************
** Authentication binds both row and column without changing the storage prefix.
*******************************************************************************/
@Test
void ciphertextSwapFails()
{
String first = EmailBodyEncryption.encrypt("first", "htmlBody", "1");
String second = EmailBodyEncryption.encrypt("second", "htmlBody", "2");
assertThat(first).startsWith("enc:v1:");
assertThat(EmailBodyEncryption.decrypt(first, "htmlBody", "1")).isEqualTo("first");
assertThatThrownBy(() -> EmailBodyEncryption.decrypt(first, "htmlBody", "2")).hasMessageContaining("could not be opened");
assertThatThrownBy(() -> EmailBodyEncryption.decrypt(second, "htmlBody", "1")).hasMessageContaining("could not be opened");
assertThatThrownBy(() -> EmailBodyEncryption.decrypt(first, "textBody", "1")).hasMessageContaining("could not be opened");
}
/*******************************************************************************
** Swapped stored ciphertext never reaches a transport during delivery.
*******************************************************************************/
@Test
void swappedRowsCannotBeDispatched() throws Exception
{
QRecord version = EmailTemplatesTest.draft("protected", "HTML", "[]", "{}", "<p>Private message</p>");
EmailVersions.approve(version.getValueInteger("id"));
EmailVersions.activate(version.getValueInteger("id"));
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("phiAllowed", true));
var first = EmailService.send(new EmailRequest().withTemplateName("protected").withTo("a@example.com"));
var second = EmailService.send(new EmailRequest().withTemplateName("protected").withTo("b@example.com"));
QRecord original = EmailStore.get(EmailTables.MESSAGE, first.messageId(), null);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", second.messageId()).withValue("htmlBody", original.getValue("htmlBody")));
var dispatcher = new com.kingsrook.qbits.email.delivery.EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry()
.withTransport("SMTP", (envelope, settings) -> { throw new AssertionError("Swapped ciphertext reached transport"); }));
assertThat(dispatcher.sendNow(second.messageId())).isEqualTo(com.kingsrook.qbits.email.delivery.EmailDispatcher.SendResult.RETRY_WAIT);
}
/*******************************************************************************
** Exercise the actual inline widget with current user permissions.
*******************************************************************************/
private String preview(QRecord version) throws Exception
{
return (((RawHTML) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("emailTemplatePreview"))
.withUrlParams(Map.of("id", version.getValueString("id")))).getWidgetData()).getHtml());
}
}
@@ -0,0 +1,345 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetup;
import com.kingsrook.qbits.email.app.EmailHome;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.Multipart;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Version lifecycle, permissions, metadata filters, previews and real delivery.
*******************************************************************************/
class EmailTemplatesTest
{
/*******************************************************************************
** Isolated database and sender session.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(c -> c.withPublicBaseUrl("https://app.example.com"));
H2Support.provider(25);
}
/*******************************************************************************
** Clear thread and clock state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** Reusable real template/version fixture.
*******************************************************************************/
static QRecord draft(String name, String kind, String schema, String samples, String body) throws Exception
{
QRecord template = H2Support.insert(EmailTables.TEMPLATE, new QRecord().withValue("name", name).withValue("kind", kind).withValue("category", "TRANSACTIONAL"));
QRecord version = EmailVersions.newVersion(template.getValueInteger("id"));
QRecord update = new QRecord().withValue("id", version.getValue("id")).withValue("subject", "Hello {{brand.label}} {{message.id}}")
.withValue("bodySource", body).withValue("variablesSchema", schema).withValue("sampleVariables", samples);
var output = new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(update)));
assertThat(output.getRecords().getFirst().getErrors()).isNullOrEmpty();
return (EmailStore.get(EmailTables.VERSION, version.getValueInteger("id"), null));
}
/*******************************************************************************
** Review/Result creates drafts and atomically retires the old active locale.
*******************************************************************************/
@Test
void lifecycleActionsAndImmutability() throws Exception
{
QRecord first = draft("welcome", "MARKDOWN", "[]", "{}", "Hello");
assertThat(ProcessRunner.run(EmailProcesses.ACTIVATE_VERSION, first.getValue("id")).result().toString()).contains("ACTIVE");
var attempted = new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(new QRecord().withValue("id", first.getValue("id")).withValue("bodySource", "changed"))));
assertThat(attempted.getRecords().getFirst().getErrors()).isNotEmpty();
assertThat(ProcessRunner.run(EmailProcesses.NEW_VERSION, first.getValue("templateId")).result().toString()).contains("DRAFT 2");
QRecord next = H2Support.all(EmailTables.VERSION).stream().filter(v -> v.getValueInteger("versionNumber") == 2).findFirst().orElseThrow();
assertThat(next.getValueString("bodySource")).isEqualTo("Hello");
EmailVersions.activate(next.getValueInteger("id"));
assertThat(EmailStore.get(EmailTables.VERSION, first.getValueInteger("id"), null).getValueString("status")).isEqualTo("RETIRED");
assertThat(EmailStore.get(EmailTables.TEMPLATE, first.getValueInteger("templateId"), null).getValue("activeVersionId")).isEqualTo(next.getValue("id"));
assertThatThrownBy(() -> EmailVersions.activate(first.getValueInteger("id"))).hasMessageContaining("DRAFT");
attempted = new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(new QRecord().withValue("id", first.getValue("id")).withValue("status", "DRAFT"))));
assertThat(attempted.getRecords().getFirst().getErrors()).isNotEmpty();
}
/*******************************************************************************
** New PHI versions need their own approval and drafts freeze on approval.
*******************************************************************************/
@Test
void phiApprovalAndEditPermission() throws Exception
{
QRecord version = draft("private_notice", "HTML", "[{\"name\":\"patient\",\"type\":\"STRING\",\"classification\":\"PHI\"}]", "{\"patient\":\"sample\"}", "<p>{{patient}}</p>");
assertThatThrownBy(() -> EmailVersions.activate(version.getValueInteger("id"))).hasMessageContaining("patient is PHI");
QContext.getQSession().setPermissions(Set.of("email.manage"));
assertThatThrownBy(() -> EmailVersions.approve(version.getValueInteger("id"))).hasMessageContaining("email.admin");
QContext.getQSession().setPermissions(Set.of("email.admin"));
assertThat(ProcessRunner.run(EmailProcesses.APPROVE_PHI, version.getValue("id")).result().toString()).contains("Approved");
var edit = new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(new QRecord().withValue("id", version.getValue("id")).withValue("bodySource", "replaced"))));
assertThat(edit.getRecords().getFirst().getErrors()).isNotEmpty();
EmailVersions.activate(version.getValueInteger("id"));
QRecord next = EmailVersions.newVersion(version.getValueInteger("templateId"));
assertThatThrownBy(() -> EmailVersions.activate(next.getValueInteger("id"))).hasMessageContaining("not approved");
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("editPermissionName", "clinical.edit"));
assertThatThrownBy(() -> EmailVersions.newVersion(version.getValueInteger("templateId"))).hasMessageContaining("clinical.edit");
QContext.getQSession().setPermissions(Set.of("email.admin", "clinical.edit"));
assertThat(EmailVersions.newVersion(version.getValueInteger("templateId")).getValueInteger("versionNumber")).isEqualTo(3);
}
/*******************************************************************************
** MJML requires activation compilation, and sending never runs it again.
*******************************************************************************/
@Test
void mjmlCompilerAndLocaleFallback() throws Exception
{
QRecord version = draft("mjml", "MJML", "[]", "{}", "<mjml><mj-body>{{brand.label}}</mj-body></mjml>");
assertThatThrownBy(() -> EmailVersions.activate(version.getValueInteger("id"))).hasMessageContaining("configured compiler");
EmailRuntime.config().withMjmlCompiler(source -> "<html><body><p>{{brand.label}}</p></body></html>");
var preview = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("emailTemplatePreview"))
.withUrlParams(Map.of("id", version.getValueString("id"))));
assertThat(((RawHTML) preview.getWidgetData()).getHtml()).contains("<p>Email</p>");
assertThat(EmailStore.get(EmailTables.VERSION, version.getValueInteger("id"), null).getValue("compiledHtml")).isNull();
EmailVersions.activate(version.getValueInteger("id"));
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Compiler ran at send time"); });
var result = EmailService.send(new EmailRequest().withTemplateName("mjml").withLocale("fr").withTo("p@example.com"));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
QRecord row = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
assertThat(row.getValueString("locale")).isEqualTo("en");
assertThat(row.getValueString("htmlBody")).contains("<p>Email</p>");
assertThat(row.getValueString("subject")).endsWith(result.messageId().toString());
}
/*******************************************************************************
** Template categories and retained resend links enforce the same mailbox boundary.
*******************************************************************************/
@Test
void unsubscribeTemplateCategoryAndResend() throws Exception
{
EmailRuntime.config().withSecretResolver(name -> "test-unsubscribe-key");
QRecord version = draft("notice", "MARKDOWN", "[]", "{}", "A notification");
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("category", "NOTIFICATION"));
EmailVersions.activate(version.getValueInteger("id"));
var refused = EmailService.send(new EmailRequest().withTemplateName("notice").withTo("person@example.com").withBcc("other@example.com"));
assertThat(refused.status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(refused.reasons()).containsExactly("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient.");
var queued = EmailService.send(new EmailRequest().withTemplateName("notice").withTo("\"Person, One\" <PERSON@example.com>"));
assertThat(queued.status()).isEqualTo(EmailSendResult.Status.QUEUED);
var resent = EmailService.resend(queued.messageId());
assertThat(resent.status()).isEqualTo(EmailSendResult.Status.QUEUED);
QRecord message = EmailStore.get(EmailTables.MESSAGE, queued.messageId(), null);
String url = message.getValueString("unsubscribeUrl");
assertThat(EmailStore.get(EmailTables.MESSAGE, resent.messageId(), null).getValueString("unsubscribeUrl")).isEqualTo(url);
new com.kingsrook.qbits.email.tracking.EmailUnsubscribe().unsubscribe(url.substring(url.lastIndexOf('/') + 1));
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1).allSatisfy(row -> assertThat(row.getValueString("address")).isEqualTo("person@example.com"));
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", queued.messageId()).withValue("ccAddresses", "other@example.com"));
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(EmailService.resend(queued.messageId()).status()).isEqualTo(EmailSendResult.Status.REFUSED);
}
/*******************************************************************************
** Branded rendered templates reach a real SMTP sink with both alternatives.
*******************************************************************************/
@Test
void greenMailAndBuiltIn() throws Exception
{
GreenMail mail = new GreenMail(new ServerSetup(0, "127.0.0.1", "smtp"));
try
{
mail.start();
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("port", mail.getSmtp().getPort()));
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "blue").withValue("label", "Blue Brand").withValue("primaryColor", "#123456").withValue("isDefault", true));
var result = EmailService.send(new EmailRequest().withTemplateName("email_secure_message").withTo("p@example.com").withVariable("link", "https://example.com/secure"));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
assertThat(new EmailDispatcher().runOnce().sent()).isEqualTo(1);
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
Multipart parts = (Multipart) mail.getReceivedMessages()[0].getContent();
assertThat(parts.getBodyPart(0).getContent().toString()).contains("Blue Brand", "https://example.com/secure");
assertThat(parts.getBodyPart(1).getContent().toString()).contains("#123456", "email-button", "Blue Brand");
EmailService.send(new EmailRequest().withTemplateName("email_secure_message").withTo("p@example.com").withVariable("link", "https://example.com/secure"));
assertThat(H2Support.all(EmailTables.TEMPLATE)).hasSize(1);
}
finally
{
mail.stop();
}
}
/*******************************************************************************
** Preview and home render through the standard widget endpoint, with empties.
*******************************************************************************/
@Test
void previewAndHome() throws Exception
{
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("<style", "overflow:auto");
assertThat(EmailHome.hourly().getSublabel()).contains("No completed attempts");
assertThat(EmailHome.suppressions().getSublabel()).contains("No new suppressions");
QRecord version = draft("preview", "HTML", "[]", "{}", "<html><body><p>Preview text</p><script>evil()</script><img src=x onerror=evil()></body></html>");
var widget = QContext.getQInstance().getWidget("emailTemplatePreview");
var output = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(widget).withUrlParams(Map.of("id", version.getValueString("id"))));
String html = ((RawHTML) output.getWidgetData()).getHtml();
assertThat(html).contains("Light", "Dark", "390px", "600px", "Plain text", "Preview text").doesNotContain("<iframe", "<script", "onerror", "<style");
EmailVersions.activate(version.getValueInteger("id"));
EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person <person@example.com>"));
assertThat(EmailHome.overview().getHtml()).contains("p***@example.com", "/app/email_message/");
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(EmailHome.overview().getHtml()).contains("needs email.admin");
}
/*******************************************************************************
** Exact metrics use event dates and viewer permissions, with no sample limits.
*******************************************************************************/
@Test
void homeCountsAndBrandPrecedence() throws Exception
{
java.time.Instant now = java.time.Instant.parse("2026-10-13T12:00:00Z");
EmailRuntime.setClock(java.time.Clock.fixed(now, java.time.ZoneOffset.UTC));
SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("status", "DELIVERED").withValue("subject", "Visible")
.withValue("toAddresses", "person@example.com").withValue("sentDate", now.minusSeconds(60)).withValue("deliveredDate", now)
.withValue("bouncedDate", now.minusSeconds(2 * 86400)).withValue("complainedDate", now.minusSeconds(8 * 86400)));
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", 1).withValue("providerId", 1).withValue("attemptNumber", 1)
.withValue("outcome", "SUCCEEDED").withValue("startDate", now.minusSeconds(10)).withValue("endDate", now));
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "x@example.com").withValue("scope", "ALL").withValue("reason", "MANUAL"));
for(String type : List.of("DELIVERED", "BOUNCED", "COMPLAINED"))
{
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", type).withValue("eventKey", type)
.withValue("occurredDate", "DELIVERED".equals(type) ? now : now.minusSeconds(("BOUNCED".equals(type) ? 2 : 8) * 86400)));
}
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", "DELIVERED").withValue("eventKey", "second-delivery")
.withValue("occurredDate", now.minusSeconds(60)));
var tiles = org.jsoup.Jsoup.parse(EmailHome.overview().getHtml()).select("body > div > div > div");
assertThat(tiles).extracting(org.jsoup.nodes.Element::text).containsExactly("1 Sent (24 h)", "2 Delivered (24 h)", "0 Dead letters (24 h)", "1 Bounced (7 d)", "0 Complaints (7 d)", "1 Suppressed addresses");
assertThat(EmailHome.hourly().getChartData().getDatasets().getFirst().getData()).contains(1);
assertThat(EmailHome.suppressions().getChartData().getDatasets().get(3).getData()).contains(1);
QRecord defaultBrand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default").withValue("isDefault", true));
QRecord appBrand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "app").withValue("label", "App").withValue("appName", "email"));
assertThat(com.kingsrook.qbits.email.render.EmailBrands.resolve(null, null).getValue("id")).isEqualTo(appBrand.getValue("id"));
assertThat(com.kingsrook.qbits.email.render.EmailBrands.resolve(null, new QRecord().withValue("brandId", defaultBrand.getValue("id"))).getValue("id")).isEqualTo(defaultBrand.getValue("id"));
assertThat(com.kingsrook.qbits.email.render.EmailBrands.resolve("app", new QRecord().withValue("brandId", defaultBrand.getValue("id"))).getValue("id")).isEqualTo(appBrand.getValue("id"));
assertThatThrownBy(() -> H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "bad").withValue("textColor", "#ffffff")))
.hasMessageContaining("needs 4.5:1");
}
/*******************************************************************************
** Signed links bind the real database id, and HTML footers precede body end.
*******************************************************************************/
@Test
void signedNotificationFooter() throws Exception
{
EmailRuntime.config().withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? "test-unsubscribe-key" : null);
QRecord version = draft("notice", "HTML", "[]", "{}", "<html><body><p>Notice</p></body></html>");
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("category", "NOTIFICATION"));
EmailVersions.activate(version.getValueInteger("id"));
var result = EmailService.send(new EmailRequest().withTemplateName("notice").withTo("person@example.com"));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
QRecord message = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
String link = message.getValueString("unsubscribeUrl");
assertThat(link).startsWith("https://app.example.com/qqq/hooks/email/unsubscribe/v1.").doesNotContain("person@example.com");
String token = link.substring(link.lastIndexOf('/') + 1);
String payload = new String(java.util.Base64.getUrlDecoder().decode(token.split("\\.")[1]), java.nio.charset.StandardCharsets.UTF_8);
assertThat(payload).contains("\"messageId\":\"" + result.messageId() + "\"", "NOTIFICATION", "addressHash");
assertThat(message.getValueString("htmlBody").indexOf("Unsubscribe")).isLessThan(message.getValueString("htmlBody").indexOf("</body>"));
var dispatcher = new EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry().withTransport("SMTP", (envelope, settings) ->
{
assertThat(envelope.getHeaders()).containsEntry("List-Unsubscribe", "<" + link + ">");
return new com.kingsrook.qbits.email.transport.EmailTransportResult("signed");
}));
assertThat(dispatcher.sendNow(result.messageId())).isEqualTo(EmailDispatcher.SendResult.SENT);
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "address", "NOTIFICATION")).isNull();
assertThatThrownBy(() -> com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "address", "MARKETING")).hasMessageContaining("QQQ_EMAIL_UNSUBSCRIBE_KEY");
EmailRuntime.config().withSecretResolver(name -> { throw new IllegalArgumentException("resolver detail"); });
assertThatThrownBy(() -> com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "person@example.com", "NOTIFICATION"))
.hasMessage("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY");
}
/*******************************************************************************
** Template active-version possible values are scoped to the template record.
*******************************************************************************/
@Test
void templateVersionPossibleValues() throws Exception
{
possibleValues(EmailTables.TEMPLATE, "activeVersionId", "id");
}
/*******************************************************************************
** Message version possible values follow the selected template input.
*******************************************************************************/
@Test
void messageVersionPossibleValues() throws Exception
{
possibleValues(EmailTables.MESSAGE, "templateVersionId", "templateId");
}
/*******************************************************************************
** Approval pointers use the same parent-scoped possible values.
*******************************************************************************/
@Test
void approvedVersionPossibleValues() throws Exception
{
possibleValues(EmailTables.TEMPLATE, "approvedVersionId", "id");
}
/*******************************************************************************
** Execute core's possible-value search with the interpreted form binding.
*******************************************************************************/
private void possibleValues(String table, String field, String inputName) throws Exception
{
QRecord a = draft("a", "HTML", "[]", "{}", "A");
QRecord b = draft("b", "HTML", "[]", "{}", "B");
var metadata = QContext.getQInstance().getTable(table).getField(field);
assertThat(metadata.getPossibleValueSourceFilter()).isNotNull();
var filter = metadata.getPossibleValueSourceFilter().clone();
assertThat(filter.toString()).contains("${input." + inputName + "}");
filter.interpretValues(Map.of(inputName, a.getValue("templateId")));
var found = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(metadata.getPossibleValueSourceName()).withDefaultQueryFilter(filter));
assertThat(found.getResults().stream().map(v -> v.getId().toString()).toList()).contains(a.getValueString("id")).doesNotContain(b.getValueString("id"));
}
}
@@ -20,8 +20,6 @@
package com.kingsrook.qbits.email;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.util.List;
import java.util.UUID;
@@ -72,7 +70,7 @@ public final class H2Support
{
try(Connection connection = new ConnectionManager().getConnection(backend))
{
for(String sql : Files.readString(Path.of("src/test/resources/email-h2.sql")).split(";"))
for(String sql : new String(H2Support.class.getResourceAsStream("/email-h2.sql").readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split(";"))
{
if(!sql.isBlank())
{
@@ -0,0 +1,419 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.io.Serializable;
import java.math.BigDecimal;
import java.sql.Connection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qbits.email.app.EmailHome;
import com.kingsrook.qbits.email.bridge.EmailTemplatedMessageInput;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.phi.EmailBodyEncryption;
import com.kingsrook.qbits.email.phi.EmailClassifications;
import com.kingsrook.qbits.email.phi.EmailValueTokens;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Canary egress across every templated entry path, retry, dead letter, export,
** home and Log4j. Transport receives plaintext; no other inspected surface does.
*******************************************************************************/
class PhiCanaryEgressTest
{
private String canary;
private QRecord version;
private QTableMetaData patient;
/*******************************************************************************
** Plant PHI in an application table and configure only classification overrides.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
canary = "CANARY-PHI-" + UUID.randomUUID();
H2Support.setup(c -> c.withMaxAttempts(2).withSecretResolver(PhiCanaryEgressTest::secret)
.withClassificationOverrides(Map.of("test_patient.name", EmailClassifications.Level.PHI)));
H2Support.provider(25);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("phiAllowed", true));
patient = new QTableMetaData().withName("test_patient").withBackendName("rdbms").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withBackendName("id"))
.withField(new QFieldMetaData("name", QFieldType.STRING).withBackendName("name"));
QContext.getQInstance().addTable(patient);
try(Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms")))
{
connection.createStatement().execute("CREATE TABLE test_patient (id INTEGER PRIMARY KEY, name VARCHAR(200))");
try(var statement = connection.prepareStatement("INSERT INTO test_patient (id, name) VALUES (1, ?)"))
{
statement.setString(1, canary);
statement.executeUpdate();
}
}
version = EmailTemplatesTest.draft("phi_notice", "MARKDOWN", "[{\"name\":\"patient\",\"type\":\"RECORD\",\"required\":true}]",
"{\"patient\":{\"name\":\"Example\"}}", "# {{patient.name}}\n\nPrivate message");
SystemWrites.updateOne(EmailTables.VERSION, new QRecord().withValue("id", version.getValue("id")).withValue("subject", "{{patient.name}}"));
}
private static boolean coreVectorKey = false;
/*******************************************************************************
** The stand-in matches core ClassifiedValueTokens (QRun-IO/qqq#1182) on its
** published context vector: key bytes 00..1f, key id env1.
*******************************************************************************/
@Test
void standInMatchesCoreVector()
{
coreVectorKey = true;
try
{
assertThat(EmailValueTokens.forContext(List.of("email", "welcome", "recipient", "message-7"), "Ada@example.com"))
.isEqualTo("hmac:v1:env1:p1BggBbb8ytFgS5YeM6eBiriGgC1j3x0YGjics5qVeM");
}
finally
{
coreVectorKey = false;
}
}
/*******************************************************************************
** Fixed non-production keys, resolved in memory only.
*******************************************************************************/
private static String secret(String name)
{
if(coreVectorKey && "QQQ_AUDIT_HMAC_KEY".equals(name))
{
byte[] key = new byte[32];
for(int i = 0; i < key.length; i++)
{
key[i] = (byte) i;
}
return (Base64.getEncoder().encodeToString(key));
}
return (switch(name)
{
case "QQQ_EMAIL_BODY_KEY" -> "test-only-body-key";
case "QQQ_AUDIT_HMAC_KEY" -> Base64.getEncoder().encodeToString("test-only-audit-key-32-bytes-long!".getBytes(java.nio.charset.StandardCharsets.UTF_8));
default -> null;
});
}
/*******************************************************************************
** Clear request-local state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** The record is fetched under the existing sender session at send time.
*******************************************************************************/
private EmailRequest request()
{
return (new EmailRequest().withTemplateName("phi_notice").withTo("person@example.com").withRecordVariable("patient", "test_patient", 1));
}
/*******************************************************************************
** No unapproved PHI request can leave any outbox or attempt row.
*******************************************************************************/
@Test
void unapprovedRefusedWithoutStorage() throws Exception
{
EmailVersions.activate(version.getValueInteger("id"));
var refused = EmailService.send(request());
assertThat(refused.status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(refused.reasons()).contains("The variable patient.name is PHI; this template is not approved for PHI.");
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty();
noCanary(refused.toString());
}
/*******************************************************************************
** Every path encrypts, redacts, retries and dead-letters without egress.
*******************************************************************************/
@Test
void canaryAcrossServiceProcessBridgeAndOperationalSurfaces() throws Exception
{
EmailVersions.approve(version.getValueInteger("id"));
EmailVersions.activate(version.getValueInteger("id"));
List<String> logs = new ArrayList<>();
AbstractAppender capture = new AbstractAppender("phi-canary", null, null, true, null)
{
@Override
public void append(LogEvent event)
{
logs.add(event.getMessage().getFormattedMessage());
if(event.getThrown() != null)
{
logs.add(event.getThrown().toString());
}
}
};
capture.start();
LoggerContext context = (LoggerContext) LogManager.getContext(false);
var root = context.getConfiguration().getRootLogger();
var priorLevel = root.getLevel();
root.addAppender(capture, org.apache.logging.log4j.Level.ALL, null);
root.setLevel(org.apache.logging.log4j.Level.INFO);
context.updateLoggers();
try
{
var sent = EmailService.send(request());
assertThat(sent.status()).isEqualTo(EmailSendResult.Status.QUEUED);
var input = new RunProcessInput();
input.setProcessName(EmailProcesses.SEND_TEMPLATED);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
input.addValue("templateName", "phi_notice");
input.addValue("to", "person@example.com");
input.addValue("recordVariablesJson", "{\"patient\":{\"table\":\"test_patient\",\"id\":1}}");
var process = new RunProcessAction().execute(input);
assertThat(process.getException()).isEmpty();
assertThat(process.getValues().get("status")).isEqualTo("QUEUED");
noCanary(process.getValues().toString());
new SendMessageAction().execute(new EmailTemplatedMessageInput().withEmailRequest(request()).withMessagingProviderName("qbitEmail"));
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(3);
var dispatcher = new EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry().withTransport("SMTP", (envelope, settings) ->
{
assertThat(envelope.getSubject()).isEqualTo(canary);
assertThat(envelope.getHtmlBody()).contains(canary);
throw new com.kingsrook.qbits.email.transport.EmailTransportException("Provider echoed " + canary, false);
}));
for(QRecord message : H2Support.all(EmailTables.MESSAGE))
{
Integer id = message.getValueInteger("id");
assertThat(dispatcher.sendNow(id)).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
assertThat(dispatcher.sendNow(id)).isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
QRecord raw = EmailStore.get(EmailTables.MESSAGE, id, null);
for(String field : List.of("subject", "htmlBody", "textBody"))
{
assertThat(raw.getValueString(field)).startsWith("enc:v1:").doesNotContain(canary);
assertThat(EmailBodyEncryption.decrypt(raw.getValueString(field), field, raw.getValueString("id"))).contains(canary);
}
assertThat(raw.getValueString("variablesSnapshot")).contains("hmac:v1:");
for(var value : raw.getValues().entrySet())
{
if(!List.of("subject", "htmlBody", "textBody").contains(value.getKey()))
{
noCanary(String.valueOf(value.getValue()));
}
}
}
for(String table : List.of(EmailTables.MESSAGE, EmailTables.ATTEMPT))
{
var exported = new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withShouldGenerateDisplayValues(true));
noCanary(exported.getRecords().toString());
if(table.equals(EmailTables.MESSAGE))
{
assertThat(exported.getRecords()).allSatisfy(row ->
{
assertThat(row.getValueString("subject")).isEqualTo("[encrypted]");
assertThat(row.getValueString("htmlBody")).isEqualTo("[encrypted]");
assertThat(row.getValueString("textBody")).isEqualTo("[encrypted]");
});
}
}
noCanary(EmailHome.overview().getHtml());
assertThat(EmailHome.overview().getHtml()).contains("Secure message");
noCanary(EmailHome.hourly().getSublabel());
assertThat(logs).isNotEmpty();
logs.forEach(this::noCanary);
}
finally
{
root.removeAppender(capture.getName());
root.setLevel(priorLevel);
context.updateLoggers();
capture.stop();
}
}
/*******************************************************************************
** Checking every five-character window detects every longer substring too.
*******************************************************************************/
private void noCanary(String output)
{
for(int i = 0; i <= canary.length() - 5; i++)
{
assertThat(output).doesNotContain(canary.substring(i, i + 5));
}
}
/*******************************************************************************
** Provider attestation, tracking, encryption key and category are mandatory.
*******************************************************************************/
@Test
void phiPolicyAndKeyRefusals() throws Exception
{
EmailVersions.approve(version.getValueInteger("id"));
EmailVersions.activate(version.getValueInteger("id"));
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(EmailService.send(request()).reasons()).contains("PHI email needs QQQ_EMAIL_BODY_KEY");
EmailRuntime.config().withSecretResolver(PhiCanaryEgressTest::secret);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("trackOpens", true));
assertThat(EmailService.send(request()).status()).isEqualTo(EmailSendResult.Status.REFUSED);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("trackOpens", false).withValue("phiAllowed", false));
assertThat(EmailService.send(request()).status()).isEqualTo(EmailSendResult.Status.REFUSED);
assertThat(EmailService.send(request().withCategory(com.kingsrook.qbits.email.outbox.EmailCategory.MARKETING)).reasons().toString()).contains("Template category cannot be overridden");
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** Masked fields remain masked when rendered, even for an approved template.
*******************************************************************************/
@Test
void recordMaskingAndProviderChangesAreRespected() throws Exception
{
EmailVersions.approve(version.getValueInteger("id"));
EmailVersions.activate(version.getValueInteger("id"));
patient.getField("name").withFieldSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock()
.withSecurityKeyType("clinical").withDefaultBehavior(com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock.Behavior.DENY)
.withOverrideValues(List.of("allowed")));
var result = EmailService.send(request());
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
var row = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
assertThat(EmailBodyEncryption.decrypt(row.getValueString("subject"), "subject", row.getValueString("id"))).isEqualTo("************");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("trackClicks", true));
var dispatcher = new EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry().withTransport("SMTP", (envelope, settings) ->
{
throw new AssertionError("Unsafe provider received PHI");
}));
assertThat(dispatcher.sendNow(result.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
assertThat(EmailService.resend(result.messageId()).status()).isEqualTo(EmailSendResult.Status.REFUSED);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("trackClicks", false));
var resent = EmailService.resend(result.messageId());
assertThat(resent.status()).isEqualTo(EmailSendResult.Status.QUEUED);
QRecord copy = EmailStore.get(EmailTables.MESSAGE, resent.messageId(), null);
assertThat(copy.getValueBoolean("phiApproved")).isTrue();
assertThat(copy.getValueString("subject")).isNotEqualTo(row.getValueString("subject"));
assertThat(EmailBodyEncryption.decrypt(copy.getValueString("subject"), "subject", copy.getValueString("id"))).isEqualTo("************");
assertThatThrownBy(() -> EmailBodyEncryption.decrypt(copy.getValueString("subject"), "subject", row.getValueString("id"))).hasMessageContaining("could not be opened");
assertThat(copy.getValue("templateVersionId")).isEqualTo(row.getValue("templateVersionId"));
}
/*******************************************************************************
** Ad-hoc callers cannot turn a ciphertext string into a decryption request.
*******************************************************************************/
@Test
void ciphertextPrefixIsNotAuthority() throws Exception
{
String encrypted = EmailBodyEncryption.encrypt(canary, "subject", "test");
var result = EmailService.send(new EmailRequest().withTo("p@example.com").withSubject(encrypted).withTextBody(encrypted));
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
var dispatcher = new EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry().withTransport("SMTP", (envelope, settings) ->
{
assertThat(envelope.getSubject()).isEqualTo(encrypted);
assertThat(envelope.getTextBody()).isEqualTo(encrypted);
return new com.kingsrook.qbits.email.transport.EmailTransportResult("safe");
}));
assertThat(dispatcher.sendNow(result.messageId())).isEqualTo(EmailDispatcher.SendResult.SENT);
}
/*******************************************************************************
** Token bindings, canonicalization, sentinels and authenticated encryption.
*******************************************************************************/
@Test
void tokensAndEncryption()
{
List<String> binding = List.of("email", "template", "variable", "123");
String token = EmailValueTokens.forContext(binding, canary);
assertThat(token).startsWith("hmac:v1:");
assertThat(EmailValueTokens.forContext(binding, canary)).isEqualTo(token);
assertThat(EmailValueTokens.verifyContext(binding, canary, token)).isTrue();
assertThat(EmailValueTokens.verifyContext(List.of("different"), canary, token)).isFalse();
assertThat(EmailValueTokens.forContext(binding, true)).isEqualTo("changed:v1");
assertThat(EmailValueTokens.forContext(binding, "aaaaaa")).isEqualTo("changed:v1");
assertThat(EmailValueTokens.verifyContext(binding, true, "shred:v1")).isFalse();
assertThat(EmailValueTokens.verifyContext(binding, true, "changed:v1")).isFalse();
String record = EmailValueTokens.forRecordField(patient, patient.getField("name"), 1, canary);
assertThat(EmailValueTokens.verifyRecordField(patient, patient.getField("name"), 1, canary, record)).isTrue();
assertThat(EmailValueTokens.verifyRecordField(patient, patient.getField("name"), 2, canary, record)).isFalse();
assertThat(EmailValueTokens.canonical(new BigDecimal("1.2000"))).isEqualTo("1.2");
assertThat(EmailValueTokens.canonical(Instant.parse("2026-10-13T12:00:00Z"))).isEqualTo("2026-10-13T12:00:00Z");
String encrypted = EmailBodyEncryption.encrypt(canary, "subject", "test");
assertThat(EmailBodyEncryption.encrypt(canary, "subject", "test")).isNotEqualTo(encrypted);
assertThat(EmailBodyEncryption.decrypt(encrypted, "subject", "test")).isEqualTo(canary);
assertThatThrownBy(() -> EmailBodyEncryption.decrypt(encrypted.substring(0, encrypted.length() - 4) + "AAAA", "subject", "test")).hasMessageContaining("could not be opened");
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(EmailValueTokens.forContext(binding, canary)).isEqualTo("changed:v1");
assertThatThrownBy(() -> EmailBodyEncryption.encrypt(canary, "subject", "test")).hasMessageContaining("QQQ_EMAIL_BODY_KEY");
}
/*******************************************************************************
** Record projection follows parsed references and snapshots fail closed.
*******************************************************************************/
@Test
void referencedFieldsOnlyAndUnclassifiedTokens() throws Exception
{
EmailRuntime.config().withClassificationOverrides(Map.of());
patient.withField(new QFieldMetaData("unused", QFieldType.STRING).withBackendName("unused"));
try(Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms")))
{
connection.createStatement().execute("ALTER TABLE test_patient ADD unused VARCHAR(200) DEFAULT 'never-load-this'");
}
SystemWrites.updateOne(EmailTables.VERSION, new QRecord().withValue("id", version.getValue("id"))
.withValue("subject", "Notice").withValue("bodySource", "{{#if patient.name}}Hello {{patient.name}}{{/if}}"));
EmailVersions.activate(version.getValueInteger("id"));
var result = EmailService.send(request());
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
QRecord row = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
assertThat(row.getValueString("htmlBody")).contains(canary);
assertThat(row.getValueString("variablesSnapshot")).contains("name", "hmac:v1:").doesNotContain(canary, "unused", "never-load-this", "\"id\"");
}
}
@@ -0,0 +1,293 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.demo;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetup;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailBuiltIns;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qbits.email.transport.EmailTransportRegistry;
import com.kingsrook.qbits.email.transport.SesEmailTransport;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.FullyAnonymousAuthenticationModule;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import software.amazon.awssdk.services.sesv2.SesV2Client;
import software.amazon.awssdk.services.sesv2.model.SendEmailResponse;
/*******************************************************************************
** Local screenshot application: H2, GreenMail and fake SES, all in memory.
** Home links already use /app/table/id, equivalent to webhooks' table prefix.
** Nothing in this test package is shipped or included in coverage.
**
** ./mvnw -o test-compile exec:java -Dexec.classpathScope=test
** -Dexec.mainClass=com.kingsrook.qbits.email.demo.EmailDemoServer -Dexec.args=8098
*******************************************************************************/
public class EmailDemoServer extends AbstractQQQApplication
{
private static final RDBMSBackendMetaData DATABASE = backend();
/*******************************************************************************
** Anonymous sessions with all email permissions, exclusively for this demo.
*******************************************************************************/
public static class DemoAuthenticationModule extends FullyAnonymousAuthenticationModule
{
/*******************************************************************************
** Local demo session.
*******************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context)
{
return (super.createSession(instance, context).withPermissions(EmailPermissions.ALL));
}
}
/*******************************************************************************
** H2 survives connections for this JVM only, without filesystem storage.
*******************************************************************************/
private static RDBMSBackendMetaData backend()
{
var backend = new RDBMSBackendMetaData().withName("rdbms").withVendor("h2").withHostName("mem").withDatabaseName("email_demo").withUsername("sa");
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
return (backend);
}
/*******************************************************************************
** Exercise SES MIME and request construction without making AWS calls.
*******************************************************************************/
private static SesV2Client fakeSes()
{
return ((SesV2Client) java.lang.reflect.Proxy.newProxyInstance(SesV2Client.class.getClassLoader(), new Class<?>[] {SesV2Client.class}, (proxy, method, args) ->
{
return (switch(method.getName())
{
case "sendEmail" -> SendEmailResponse.builder().messageId("demo-ses-" + UUID.randomUUID()).build();
case "serviceName" -> "sesv2";
case "close" -> null;
default -> throw (new UnsupportedOperationException("Unsupported demo SES call"));
});
}));
}
/*******************************************************************************
** UI actions also use the fake SES client, after startup seeding completes.
*******************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = new QInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(DATABASE);
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("rdbms")
.withPublicBaseUrl("https://kof22.example").withMaxAttempts(3).withRetryMinutes(List.of(1, 5)).withSecretResolver(System::getProperty)
.withTransportRegistryFactory(() -> new EmailTransportRegistry().withTransport("SES", new SesEmailTransport(builder -> fakeSes()))))
.produce(instance);
instance.setHomeAppName("email");
return (instance);
}
/*******************************************************************************
** Provider rows pass through actual save validation.
*******************************************************************************/
private static QRecord provider(String name, String label, String kind, int port, boolean defaultProvider) throws Exception
{
return (H2Support.insert(EmailTables.PROVIDER, new QRecord().withValue("name", name).withValue("label", label).withValue("kind", kind)
.withValue("host", "127.0.0.1").withValue("port", port).withValue("security", "NONE").withValue("region", "us-east-1")
.withValue("fromAddress", "hello@kof22.example").withValue("fromName", "Kof22").withValue("enabled", true)
.withValue("isDefault", defaultProvider).withValue("phiAllowed", true).withValue("trackOpens", false).withValue("trackClicks", false)));
}
/*******************************************************************************
** Real template/version lifecycle with sample data for the inline preview.
*******************************************************************************/
private static QRecord template(String name, String label, String kind, String category, Integer brand, String body, boolean active) throws Exception
{
QRecord template = H2Support.insert(EmailTables.TEMPLATE, new QRecord().withValue("name", name).withValue("label", label)
.withValue("kind", kind).withValue("category", category).withValue("brandId", brand));
QRecord version = EmailVersions.newVersion(template.getValueInteger("id"));
SystemWrites.updateOne(EmailTables.VERSION, new QRecord().withValue("id", version.getValue("id")).withValue("subject", label + " - {{topic}}")
.withValue("preheader", "An update from Kof22").withValue("bodySource", body)
.withValue("variablesSchema", "[{\"name\":\"name\",\"type\":\"STRING\"},{\"name\":\"topic\",\"type\":\"STRING\"}]")
.withValue("sampleVariables", "{\"name\":\"Alex Morgan\",\"topic\":\"Your community project\"}"));
if(active)
{
EmailVersions.activate(version.getValueInteger("id"));
}
return (version);
}
/*******************************************************************************
** Messages and attempts are created only by the real service and dispatcher.
*******************************************************************************/
static Map<String, Integer> seed(QInstance instance, GreenMail mail, int closedPort) throws Exception
{
H2Support.prime(DATABASE);
QContext.init(instance, H2Support.admin());
QRecord relay = provider("galaxy", "Galaxy relay (PMG)", "SMTP", mail.getSmtp().getPort(), true);
provider("ses", "Amazon SES", "SES", 25, false);
provider("backup", "Backup relay", "SMTP", closedPort, false);
QRecord brand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "Kof22").withValue("label", "Kof22")
.withValue("isDefault", true).withValue("siteUrl", "https://kof22.com").withValue("postalAddress", "100 Market Street, Chester, IL 62233")
.withValue("primaryColor", "#2459a6").withValue("textColor", "#172033").withValue("backgroundColor", "#ffffff")
.withValue("buttonTextColor", "#ffffff").withValue("darkPrimaryColor", "#93c5fd").withValue("darkButtonTextColor", "#172033")
.withValue("darkBackgroundColor", "#111827").withValue("darkTextColor", "#f9fafb").withValue("footerMarkdown", "**Kof22** - Ideas, projects, and community."));
Integer brandId = brand.getValueInteger("id");
QRecord contact = template("contact_form_received", "Contact form received", "MARKDOWN", "NOTIFICATION", brandId,
"# Thanks for reaching out, {{name}}\n\nWe received your message about **{{topic}}**. We will follow up shortly.\n\n[Visit Kof22](https://kof22.com){.button}", true);
QRecord v2 = EmailVersions.newVersion(contact.getValueInteger("templateId"));
EmailVersions.activate(v2.getValueInteger("id"));
template("alerts_notification", "Project alert", "MARKDOWN", "NOTIFICATION", brandId,
"# A project needs your attention\n\nHello {{name}}, there is an update for **{{topic}}**.\n\n| Item | Status |\n|---|---|\n| Community project | Ready for review |\n\n[Review update](https://kof22.com){.button}", true);
template("newsletter_october", "October at Kof22", "HTML", "MARKETING", brandId,
"<html><body><h1>October at Kof22</h1><p>Hello {{name}},</p><p>This month: {{topic}}.</p><h2>Building something useful</h2><p>Stories from our projects and community.</p><a href='https://kof22.com'>Explore the latest</a></body></html>", false);
EmailBuiltIns.ensure();
QRecord secure = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "email_secure_message")).getFirst();
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", secure.getValue("id")).withValue("brandId", brandId));
EmailVersions.approve(secure.getValueInteger("activeVersionId"));
Instant now = Instant.now().truncatedTo(ChronoUnit.MINUTES);
String[] reasons = {"HARD_BOUNCE", "COMPLAINT", "UNSUBSCRIBE", "MANUAL"};
for(int i = 0; i < 20; i++)
{
EmailRuntime.setClock(Clock.fixed(now.minus(Duration.ofDays(i * 29L / 19)), ZoneOffset.UTC));
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "suppressed" + i + "@example.test")
.withValue("scope", "ALL").withValue("reason", reasons[i % reasons.length]).withValue("note", "Demo suppression history"));
}
Integer messageId = null;
EmailDispatcher dispatcher = new EmailDispatcher();
for(int hour = 23; hour >= 0; hour--)
{
Instant start = now.minus(Duration.ofHours(hour)).minus(Duration.ofMinutes(25));
EmailRuntime.setClock(Clock.fixed(start, ZoneOffset.UTC));
for(int i = 0; i < 5; i++)
{
int sequence = (23 - hour) * 5 + i;
boolean phi = sequence == 37 || sequence == 88;
EmailRequest request = new EmailRequest().withTemplateName(phi ? "email_secure_message" : sequence % 3 == 0 ? "alerts_notification" : "contact_form_received")
.withProviderName(sequence % 11 == 0 ? "backup" : sequence % 5 == 0 ? "ses" : "galaxy")
.withTo(sequence % 19 == 0 ? "suppressed" + (sequence % 20) + "@example.test" : "reader" + sequence + "@example.test");
if(phi)
{
request.withVariable("link", "https://kof22.com/secure/demo-" + sequence);
}
else
{
request.withVariable("name", List.of("Alex Morgan", "Jordan Lee", "Sam Taylor", "Casey Rivera", "Riley Chen").get(i))
.withVariable("topic", List.of("Community center", "October newsletter", "Website feedback", "Volunteer program", "Project update").get(i));
}
EmailSendResult result = EmailService.send(request);
if(result.status() == EmailSendResult.Status.REFUSED)
{
throw (new IllegalStateException("Demo send refused: " + result.reasons()));
}
if(messageId == null && result.status() == EmailSendResult.Status.QUEUED)
{
messageId = result.messageId();
}
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", result.messageId()).withValue("createDate", start));
}
for(int pass = 0; pass < 3; pass++)
{
EmailRuntime.setClock(Clock.fixed(start.plus(Duration.ofMinutes(pass * 6L)), ZoneOffset.UTC));
dispatcher.runOnce();
}
}
EmailRuntime.setClock(null);
System.out.println("Seeded " + H2Support.all(EmailTables.MESSAGE).size() + " messages, " + H2Support.all(EmailTables.ATTEMPT).size() + " real attempts; " + mail.getReceivedMessages().length + " captured SMTP deliveries.");
QContext.clear();
return (Map.of("message", messageId, "version", v2.getValueInteger("id"), "provider", relay.getValueInteger("id")));
}
/*******************************************************************************
** Start SMTP and Next UI; shutdown releases in-memory mail.
*******************************************************************************/
public static void main(String[] args) throws Exception
{
int port = args.length == 0 ? 8098 : Integer.parseInt(args[0]);
System.setProperty("QQQ_EMAIL_BODY_KEY", "local-demo-only-body-key");
System.setProperty("QQQ_EMAIL_UNSUBSCRIBE_KEY", "local-demo-only-unsubscribe-key");
var field = QAuthenticationModuleDispatcher.class.getDeclaredField("authenticationTypeToModuleClassNameMap");
field.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, String> modules = (Map<String, String>) field.get(null);
modules.put(QAuthenticationType.FULLY_ANONYMOUS.getName(), DemoAuthenticationModule.class.getName());
GreenMail mail = new GreenMail(new ServerSetup(0, "127.0.0.1", "smtp"));
mail.start();
Runtime.getRuntime().addShutdownHook(new Thread(mail::stop));
int closedPort;
try(var socket = new java.net.ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress()))
{
closedPort = socket.getLocalPort();
}
try
{
EmailDemoServer application = new EmailDemoServer();
QInstance instance = application.defineQInstance();
new QInstanceValidator().validate(instance);
Map<String, Integer> ids = seed(instance, mail, closedPort);
new QApplicationJavalinServer(application).withPort(port).withServeFrontendNext(true).start();
String base = "http://localhost:" + port + "/app/";
System.out.println("Home: " + base + "email");
System.out.println("Message: " + base + EmailTables.MESSAGE + "/" + ids.get("message"));
System.out.println("Template preview: " + base + EmailTables.VERSION + "/" + ids.get("version"));
System.out.println("Provider: " + base + EmailTables.PROVIDER + "/" + ids.get("provider"));
}
catch(Exception e)
{
mail.stop();
EmailRuntime.setClock(null);
QContext.clear();
throw (e);
}
}
}
@@ -0,0 +1,55 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Compiler isolation without spawning a child or writing temporary files.
*******************************************************************************/
class CliMjmlCompilerTest
{
/*******************************************************************************
** Only PATH survives; includes are disabled even for an include-free input.
*******************************************************************************/
@Test
void isolatedProcessBuilder()
{
var builder = new CliMjmlCompiler("mjml").processBuilder();
assertThat(builder.environment().keySet()).allMatch("PATH"::equals);
assertThat(builder.command()).containsExactly("mjml", "-i", "-s", "--config.allowIncludes=false");
assertThat(builder.redirectError()).isEqualTo(ProcessBuilder.Redirect.DISCARD);
}
/*******************************************************************************
** Refuse before attempting to launch even a nonexistent executable.
*******************************************************************************/
@Test
void includesRefusedBeforeLaunch()
{
assertThatThrownBy(() -> new CliMjmlCompiler("nonexistent").compile("<MJ-INCLUDE path='private'/>"))
.hasMessage("MJML includes are not allowed");
}
}
@@ -0,0 +1,167 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.*;
import static org.assertj.core.api.Assertions.*;
/*******************************************************************************
** Provider-specific normalization contracts use realistic fixture envelopes.
*******************************************************************************/
class EmailEventParserTest
{
/*******************************************************************************
** All SES event types, with notificationType fallback and header correlation.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"Delivery,DELIVERED", "Bounce,BOUNCED", "Complaint,COMPLAINED", "Reject,REJECTED", "DeliveryDelay,DEFERRED", "Open,OPENED", "Click,CLICKED"})
void sesTypes(String raw, String expected) throws Exception
{
JSONObject envelope = new JSONObject(resource("/webhooks/ses-bounce.json"));
JSONObject body = new JSONObject(envelope.getString("Message"));
body.remove("eventType");
body.put("notificationType", raw);
String field = "DeliveryDelay".equals(raw) ? "deliveryDelay" : raw.toLowerCase();
JSONObject data = new JSONObject().put("timestamp", NOW.toString()).put("link", "https://example.com/page").put("bounceType", "Transient");
if("Bounce".equals(raw) || "Complaint".equals(raw))
{
data.put("Bounce".equals(raw) ? "bouncedRecipients" : "complainedRecipients", new JSONArray()
.put(new JSONObject().put("emailAddress", "recipient@example.com")).put(new JSONObject().put("emailAddress", "second@example.com")));
}
body.put(field, data);
envelope.put("Message", body.toString());
var events = new EmailEventParser().parse("SES", envelope.toString(), NOW);
assertThat(events).hasSize(2).allSatisfy(event ->
{
assertThat(event.type()).isEqualTo(expected);
assertThat(event.occurred()).isEqualTo(NOW);
assertThat(event.messageId()).isEqualTo("1");
assertThat(event.url()).isEqualTo("CLICKED".equals(expected) ? "https://example.com/page" : null);
});
if("BOUNCED".equals(expected))
{
assertThat(events.getFirst().bounceType()).isEqualTo("SOFT");
}
}
/*******************************************************************************
** Postmark mappings keep subscriber reactivation out of suppression.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"Delivery,DELIVERED", "Bounce,BOUNCED", "SpamComplaint,COMPLAINED", "Open,OPENED", "Click,CLICKED", "SubscriptionChange,UNSUBSCRIBED"})
void postmarkTypes(String raw, String expected) throws Exception
{
JSONObject body = new JSONObject(resource("/webhooks/postmark-delivery.json")).put("RecordType", raw).put("SuppressSending", true)
.put("OriginalLink", "https://example.com/page").put("Type", "Transient").put("ChangedAt", NOW.toString());
var event = new EmailEventParser().parse("POSTMARK", body.toString(), NOW).getFirst();
assertThat(event.type()).isEqualTo(expected);
assertThat(event.occurred()).isEqualTo(NOW);
assertThat(event.providerMessageId()).isEqualTo("provider-message-1");
assertThat(event.url()).isEqualTo("CLICKED".equals(expected) ? "https://example.com/page" : null);
if("BOUNCED".equals(expected))
{
assertThat(event.bounceType()).isEqualTo("SOFT");
}
}
/*******************************************************************************
** SendGrid event mappings retain reason and provider-message prefix correlation.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"delivered,DELIVERED", "bounce,BOUNCED", "dropped,REJECTED", "deferred,DEFERRED", "spamreport,COMPLAINED", "open,OPENED", "click,CLICKED", "unsubscribe,UNSUBSCRIBED"})
void sendgridTypes(String raw, String expected) throws Exception
{
JSONObject body = new JSONArray(resource("/webhooks/sendgrid-batch.json")).getJSONObject(0).put("event", raw)
.put("custom_args", new JSONObject().put("qqq_email_id", "25")).put("url", "https://example.com/page");
var event = new EmailEventParser().parse("SENDGRID", new JSONArray().put(body).toString(), NOW).getFirst();
assertThat(event.type()).isEqualTo(expected);
assertThat(event.messageId()).isEqualTo("25");
assertThat(event.providerMessageId()).isEqualTo("provider-message-1");
assertThat(event.url()).isEqualTo("CLICKED".equals(expected) ? "https://example.com/page" : null);
}
/*******************************************************************************
** Mailgun's nested MIME id and both documented variable spellings correlate.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"delivered,DELIVERED", "failed,BOUNCED", "complained,COMPLAINED", "opened,OPENED", "clicked,CLICKED", "unsubscribed,UNSUBSCRIBED"})
void mailgunTypes(String raw, String expected) throws Exception
{
JSONObject body = new JSONObject(resource("/webhooks/mailgun-failed.json"));
body.getJSONObject("event-data").put("event", raw).put("severity", "temporary").put("url", "https://example.com/page")
.put("user-variables", new JSONObject().put("v:qqq_email_id", "25"));
var event = new EmailEventParser().parse("MAILGUN", body.toString(), NOW).getFirst();
assertThat(event.type()).isEqualTo(expected);
assertThat(event.messageId()).isEqualTo("25");
assertThat(event.providerMessageId()).isEqualTo("provider-message-1");
assertThat(event.url()).isEqualTo("CLICKED".equals(expected) ? "https://example.com/page" : null);
if("BOUNCED".equals(expected))
{
assertThat(event.bounceType()).isEqualTo("SOFT");
}
}
/*******************************************************************************
** Unknown events are ignored; incomplete known events fail for bounded retry.
*******************************************************************************/
@Test
void unknownEventsAndMissingFields() throws Exception
{
var parser = new EmailEventParser();
assertThat(parser.parse("SES", "{\"Type\":\"SubscriptionConfirmation\"}", NOW)).isEmpty();
assertThat(parser.parse("SES", new JSONObject().put("Type", "Notification").put("Message", "{\"eventType\":\"Send\"}").toString(), NOW)).isEmpty();
assertThat(parser.parse("POSTMARK", "{\"RecordType\":\"Unknown\"}", NOW)).isEmpty();
assertThat(parser.parse("SENDGRID", "[{\"event\":\"processed\"}]", NOW)).isEmpty();
assertThat(parser.parse("MAILGUN", "{\"event-data\":{\"event\":\"accepted\"}}", NOW)).isEmpty();
assertThatThrownBy(() -> parser.parse("POSTMARK", "invalid", NOW)).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> parser.parse("OTHER", "{}", NOW)).isInstanceOf(IllegalArgumentException.class);
for(String kind : List.of("SES", "POSTMARK", "SENDGRID", "MAILGUN"))
{
assertThatThrownBy(() -> parser.parse(kind, "{}", NOW)).isInstanceOf(RuntimeException.class);
}
assertThat(EmailEventParser.detail("550 \"name with spaces\"@example.com unavailable " + "x".repeat(600))).hasSize(500).doesNotContain("example.com", "name with spaces");
assertThat(EmailEventParser.detail("bad person+tag@example.com and other@example.org")).isEqualTo("bad [address] and [address]");
}
/*******************************************************************************
** Explicit uncertain bounce types remain uncertain, not hard suppression.
*******************************************************************************/
@Test
void undeterminedBounceTypes() throws Exception
{
var parser = new EmailEventParser();
JSONObject ses = new JSONObject(resource("/webhooks/ses-bounce.json"));
JSONObject inner = new JSONObject(ses.getString("Message"));
inner.getJSONObject("bounce").put("bounceType", "Undetermined");
assertThat(parser.parse("SES", ses.put("Message", inner.toString()).toString(), NOW).getFirst().bounceType()).isEqualTo("UNDETERMINED");
JSONObject postmark = new JSONObject(resource("/webhooks/postmark-bounce.json")).put("Type", "Unknown");
assertThat(parser.parse("POSTMARK", postmark.toString(), NOW).getFirst().bounceType()).isEqualTo("UNDETERMINED");
JSONObject mailgun = new JSONObject(resource("/webhooks/mailgun-failed.json"));
mailgun.getJSONObject("event-data").put("severity", "unknown");
assertThat(parser.parse("MAILGUN", mailgun.toString(), NOW).getFirst().bounceType()).isEqualTo("UNDETERMINED");
}
}
@@ -0,0 +1,448 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.ProcessRunner;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.*;
import static org.assertj.core.api.Assertions.*;
/*******************************************************************************
** Durable processing, rollback, suppression and race acceptance on real H2.
*******************************************************************************/
class EmailInboxProcessorTest
{
private QInstance instance;
/*******************************************************************************
** Use a fixed clock for retry and provider event times.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
instance = H2Support.setup(c -> c.withSecretResolver(name -> SECRET));
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
}
/*******************************************************************************
** Restore host-independent runtime state.
*******************************************************************************/
@AfterEach
void teardown()
{
QContext.clear();
EmailRuntime.setClock(null);
}
/*******************************************************************************
** Ingest the same authenticated payload boundary used by HTTP.
*******************************************************************************/
private static void receive(QRecord provider, String id, String body) throws Exception
{
assertThat(EmailTrackingStore.receive(provider, id, null, body.getBytes(StandardCharsets.UTF_8), null)).isEqualTo(EmailTrackingStore.Receipt.RECEIVED);
}
/*******************************************************************************
** All provider fixtures produce hard-bounce suppression and masked diagnostics.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"SES,ses-bounce.json,2", "POSTMARK,postmark-bounce.json,1", "SENDGRID,sendgrid-batch.json,2", "MAILGUN,mailgun-failed.json,1"})
void realisticFixtures(String kind, String fixture, int count) throws Exception
{
QRecord provider = provider(kind);
QRecord message = message(provider);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("toAddresses", "recipient@example.com, second@example.com"));
receive(provider, "fixture", resource("/webhooks/" + fixture));
assertThat(new EmailInboxProcessor().runOnce()).isEqualTo(1);
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(count);
assertThat(H2Support.all(EmailTables.INBOX).getFirst().getValueString("status")).isEqualTo("PROCESSED");
QRecord updated = EmailStore.get(EmailTables.MESSAGE, message.getValueInteger("id"), null);
assertThat(updated.getValueString("status")).isEqualTo("BOUNCED");
assertThat(updated.getValueInstant("bouncedDate")).isNotNull();
for(QRecord suppression : H2Support.all(EmailTables.SUPPRESSION))
{
assertThat(suppression.getValueString("scope")).isEqualTo("ALL");
assertThat(suppression.getValueString("reason")).isEqualTo("HARD_BOUNCE");
assertThat(suppression.getValueInteger("sourceMessageId")).isEqualTo(message.getValueInteger("id"));
assertThat(suppression.getValueInteger("sourceEventId")).isPositive();
}
assertThat(H2Support.all(EmailTables.EVENT)).allSatisfy(event -> assertThat(event.getValueString("detail")).doesNotContain("recipient@example.com", "second@example.com"));
assertThat(new EmailInboxProcessor().runOnce()).isZero();
}
/*******************************************************************************
** Soft bounces do not suppress; complaint dominates all later delivery events.
*******************************************************************************/
@Test
void statusOrderSuppressionUpsertsAndOverlappingBatches() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider);
JSONObject event = new JSONObject().put("email", "Recipient@EXAMPLE.COM").put("qqq_email_id", message.getValueString("id"))
.put("timestamp", NOW.getEpochSecond()).put("event", "bounce").put("type", "blocked").put("sg_event_id", "soft");
receive(provider, "one", new JSONArray().put(event).toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status")).isEqualTo("SENT");
event.put("type", "bounce").put("sg_event_id", "hard");
receive(provider, "two", new JSONArray().put(event).toString());
new EmailInboxProcessor().runOnce();
event.put("event", "spamreport").put("sg_event_id", "complaint");
receive(provider, "three", new JSONArray().put(event).toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status")).isEqualTo("COMPLAINED");
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueInstant("complainedDate")).isEqualTo(NOW);
event.put("event", "delivered").put("sg_event_id", "late-delivery");
JSONArray overlap = new JSONArray(resource("/webhooks/sendgrid-batch.json")).put(event);
receive(provider, "four", overlap.toString());
new EmailInboxProcessor().runOnce();
receive(provider, "five", overlap.toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.INBOX).getLast().getValueString("status")).isEqualTo("IGNORED");
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(6);
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status")).isEqualTo("COMPLAINED");
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
assertThat(H2Support.all(EmailTables.SUPPRESSION).getFirst().getValueString("reason")).isEqualTo("COMPLAINT");
}
/*******************************************************************************
** Provider unsubscribes cannot suppress account/security mail.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"MARKETING", "NOTIFICATION", "TRANSACTIONAL", "SECURITY"})
void categoryRule(String category) throws Exception
{
QRecord provider = provider("POSTMARK");
QRecord message = message(provider);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("category", category));
JSONObject event = new JSONObject(resource("/webhooks/postmark-delivery.json")).put("RecordType", "SubscriptionChange").put("SuppressSending", true);
receive(provider, "unsubscribe", event.toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
if(List.of("MARKETING", "NOTIFICATION").contains(category))
{
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
assertThat(H2Support.all(EmailTables.SUPPRESSION).getFirst().getValueString("scope")).isEqualTo(category);
}
else
{
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
}
receive(provider, "reactivation", event.put("SuppressSending", false).toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
}
/*******************************************************************************
** Tracking flags apply before matching, and protected mail never stores tracking.
*******************************************************************************/
@Test
void opensAndClicksOnlyWhenEnabled() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider);
JSONArray batch = new JSONArray();
for(String type : List.of("open", "click"))
{
batch.put(new JSONObject().put("event", type).put("email", "recipient@example.com").put("qqq_email_id", message.getValue("id"))
.put("sg_event_id", type).put("url", "https://example.com/link").put("timestamp", NOW.getEpochSecond()));
}
receive(provider, "off", batch.toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("trackOpens", true).withValue("trackClicks", true));
receive(provider, "on", batch.toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(2);
assertThat(H2Support.all(EmailTables.EVENT).getFirst().getValueString("url")).isNull();
assertThat(H2Support.all(EmailTables.EVENT).getLast().getValueString("url")).isEqualTo("https://example.com/link");
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("phiApproved", true));
batch.getJSONObject(0).put("sg_event_id", "protected-open");
batch.getJSONObject(1).put("sg_event_id", "protected-click");
receive(provider, "protected", batch.toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(2);
}
/*******************************************************************************
** Partial batch failure rolls back events, suppression and message status.
*******************************************************************************/
@Test
void retriesSixTimesThenReviewReprocess() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider);
receive(provider, "retry", "[{not json");
for(int i = 1; i <= 6; i++)
{
assertThat(new EmailInboxProcessor().runOnce()).isZero();
QRecord row = H2Support.all(EmailTables.INBOX).getFirst();
assertThat(row.getValueInteger("attempts")).isEqualTo(i);
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status")).isEqualTo("SENT");
if(i < 6)
{
assertThat(row.getValueString("status")).isEqualTo("PENDING");
assertThat(new EmailInboxProcessor().runOnce()).isZero();
EmailRuntime.setClock(Clock.fixed(row.getValueInstant("nextAttemptDate"), ZoneOffset.UTC));
}
}
QRecord failed = H2Support.all(EmailTables.INBOX).getFirst();
assertThat(failed.getValueString("status")).isEqualTo("FAILED");
var lines = ProcessRunner.run(EmailProcesses.REPROCESS, failed.getValue("id"));
assertThat(lines.review().toString()).contains("will be processed");
assertThat(lines.result().toString()).contains("PENDING");
assertThat(EmailInboxProcessor.reprocess(failed.getValueInteger("id"))).isFalse();
assertThat(EmailInboxProcessor.reprocess(9999)).isFalse();
SystemWrites.updateOne(EmailTables.INBOX, new QRecord().withValue("id", failed.getValue("id")).withValue("payloadJson", resource("/webhooks/sendgrid-batch.json")));
new EmailProcesses.ScheduledStep().run(new RunBackendStepInput().withProcessName(EmailProcesses.PROCESS_WEBHOOKS), new RunBackendStepOutput());
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(2);
}
/*******************************************************************************
** A foreign event never blocks a real hard bounce in the same SendGrid batch; the
** foreign one retries, then the row finishes PROCESSED with a note, not FAILED.
*******************************************************************************/
@Test
void unmatchedEventDoesNotBlockMatchedBounce() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider);
JSONArray batch = new JSONArray(resource("/webhooks/sendgrid-batch.json"));
batch.put(new JSONObject().put("email", "stranger@example.com").put("timestamp", NOW.getEpochSecond()).put("event", "bounce")
.put("type", "bounce").put("sg_event_id", "foreign").put("sg_message_id", "someone-elses-message.filter009"));
receive(provider, "mixed", batch.toString());
for(int i = 1; i <= 6; i++)
{
assertThat(new EmailInboxProcessor().runOnce()).isEqualTo(1);
QRecord row = H2Support.all(EmailTables.INBOX).getFirst();
assertThat(row.getValueInteger("attempts")).isEqualTo(i);
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(2);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
assertThat(EmailStore.get(EmailTables.MESSAGE, message.getValueInteger("id"), null).getValueString("status")).isEqualTo("BOUNCED");
if(i < 6)
{
assertThat(row.getValueString("status")).isEqualTo("PENDING");
assertThat(row.getValueString("lastError")).isEqualTo("1 event did not match a message");
EmailRuntime.setClock(Clock.fixed(row.getValueInstant("nextAttemptDate"), ZoneOffset.UTC));
}
}
QRecord done = H2Support.all(EmailTables.INBOX).getFirst();
assertThat(done.getValueString("status")).isEqualTo("PROCESSED");
assertThat(done.getValueString("lastError")).isEqualTo("1 event did not match a message");
assertThat(done.getValueInstant("processedDate")).isNotNull();
assertThat(done.getValue("nextAttemptDate")).isNull();
}
/*******************************************************************************
** Only a recipient of the matched message can be suppressed, and an SES bounce
** without bouncedRecipients is skipped rather than widened to all destinations.
*******************************************************************************/
@Test
void suppressionOnlyForRecipientsOfTheMessage() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider);
JSONObject event = new JSONObject().put("event", "bounce").put("type", "bounce").put("email", "intruder@example.com")
.put("qqq_email_id", message.getValueString("id")).put("timestamp", NOW.getEpochSecond()).put("sg_event_id", "intruder");
receive(provider, "intruder", new JSONArray().put(event).toString());
assertThat(new EmailInboxProcessor().runOnce()).isEqualTo(1);
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
QRecord row = H2Support.all(EmailTables.INBOX).getFirst();
assertThat(row.getValueString("status")).isEqualTo("IGNORED");
assertThat(row.getValueString("lastError")).contains("1 event was skipped");
assertThat(row.getValueString("lastError")).doesNotContain("intruder");
QRecord ses = provider("SES");
QRecord sesMessage = message(ses);
JSONObject envelope = new JSONObject(resource("/webhooks/ses-bounce.json"));
JSONObject body = new JSONObject(envelope.getString("Message"));
body.getJSONObject("mail").getJSONArray("headers").getJSONObject(0).put("value", sesMessage.getValueString("id"));
body.getJSONObject("bounce").remove("bouncedRecipients");
receive(ses, "no-recipients", envelope.put("Message", body.toString()).toString());
assertThat(new EmailInboxProcessor().runOnce()).isEqualTo(1);
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
assertThat(EmailStore.get(EmailTables.MESSAGE, sesMessage.getValueInteger("id"), null).getValueString("status")).isEqualTo("SENT");
}
/*******************************************************************************
** Our custom id cannot match another provider's message; callbacks may race SENT.
*******************************************************************************/
@Test
void providerBoundaryAndSendingRace() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = message(provider("POSTMARK"));
JSONObject event = new JSONObject().put("event", "delivered").put("qqq_email_id", message.getValue("id"))
.put("email", "recipient@example.com").put("sg_event_id", "cross-provider");
receive(provider, "cross", new JSONArray().put(event).toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
QRecord actual = message(provider);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", actual.getValue("id")).withValue("status", "SENDING"));
receive(provider, "racing", new JSONArray().put(event.put("qqq_email_id", actual.getValue("id")).put("sg_event_id", "racing")).toString());
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", actual.getValue("id")).withValue("status", "SENT"));
EmailRuntime.setClock(Clock.fixed(NOW.plusSeconds(61), ZoneOffset.UTC));
new EmailInboxProcessor().runOnce();
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
assertThat(EmailStore.get(EmailTables.MESSAGE, actual.getValueInteger("id"), null).getValueString("status")).isEqualTo("DELIVERED");
}
/*******************************************************************************
** Custom arguments support the entire integer primary-key range.
*******************************************************************************/
@Test
void fullIntegerMessageId() throws Exception
{
QRecord provider = provider("SENDGRID");
QRecord message = SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("id", 2000000000)
.withValue("providerId", provider.getValue("id")).withValue("status", "SENT").withValue("category", "NOTIFICATION")
.withValue("toAddresses", "recipient@example.com"));
assertThat(message.getValueInteger("id")).isEqualTo(2000000000);
JSONObject event = new JSONObject().put("event", "delivered").put("qqq_email_id", "2000000000")
.put("email", "recipient@example.com").put("sg_event_id", "large-message-id");
receive(provider, "large", new JSONArray().put(event).toString());
assertThat(new EmailInboxProcessor().runOnce()).isEqualTo(1);
assertThat(H2Support.all(EmailTables.EVENT).getFirst().getValueInteger("messageId")).isEqualTo(2000000000);
}
/*******************************************************************************
** Two processor replicas observe one receipt but only one applies it.
*******************************************************************************/
@Test
void concurrentProcessors() throws Exception
{
QRecord provider = provider("POSTMARK");
message(provider);
receive(provider, "race", resource("/webhooks/postmark-bounce.json"));
try(var workers = Executors.newFixedThreadPool(2))
{
var tasks = new ArrayList<java.util.concurrent.Future<Integer>>();
for(int i = 0; i < 2; i++)
{
tasks.add(workers.submit(() ->
{
QContext.init(instance, H2Support.admin());
try
{
return (new EmailInboxProcessor().runOnce());
}
finally
{
QContext.clear();
}
}));
}
assertThat(tasks.get(0).get(20, TimeUnit.SECONDS) + tasks.get(1).get(20, TimeUnit.SECONDS)).isEqualTo(1);
}
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
}
/*******************************************************************************
** Core's optional alert sink receives a day-scoped warning after threshold.
*******************************************************************************/
@Test
void complaintAlertThreshold() throws Exception
{
QRecord provider = provider("POSTMARK");
message(provider);
Alerts.signals.clear();
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance)
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(Alerts.class));
for(int i = 0; i < 5; i++)
{
JSONObject event = new JSONObject(resource("/webhooks/postmark-bounce.json")).put("RecordType", "SpamComplaint").put("ID", i);
receive(provider, "complaint-" + i, event.toString());
new EmailInboxProcessor().runOnce();
assertThat(Alerts.signals).hasSize(Math.max(0, i - 2));
}
assertThat(Alerts.signals).allSatisfy(signal ->
{
assertThat(signal.getAlertTypeName()).isEqualTo("email.complaint");
assertThat(signal.getDedupKey()).startsWith("complaints:");
});
assertThat(Alerts.signals.get(0).getDedupKey()).isEqualTo(Alerts.signals.get(1).getDedupKey());
}
/*******************************************************************************
** Captures content-free core alert signals.
*******************************************************************************/
public static class Alerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
{
static final List<com.kingsrook.qqq.backend.core.alerts.AlertSignal> signals = new ArrayList<>();
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public void raise(com.kingsrook.qqq.backend.core.alerts.AlertSignal signal)
{
signals.add(signal);
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public void resolve(com.kingsrook.qqq.backend.core.alerts.AlertResolution resolution)
{
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public void heartbeat(String source, java.time.Instant timestamp)
{
}
}
}
@@ -0,0 +1,512 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.time.Clock;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.webhook.EmailWebhookRouteProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import io.javalin.Javalin;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.*;
import static org.assertj.core.api.Assertions.*;
/*******************************************************************************
** Real random-port HTTP exercises the exact route provider and database boundary.
*******************************************************************************/
class EmailWebhookRoutesTest
{
private Javalin server;
private HttpClient client;
private QInstance instance;
private final List<String> fetched = new java.util.concurrent.CopyOnWriteArrayList<>();
private static final String BASE = "/custom/email";
/*******************************************************************************
** Test the configurable path and injected SNS HTTP seam.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
instance = H2Support.setup(c -> c.withWebhookBasePath(BASE).withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> SECRET));
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
String pem = resource("/sns-test/test-only-certificate.pem");
var routes = new EmailWebhookRouteProvider(new SnsVerifier(request ->
{
fetched.add(request.uri().toString());
return (response(request, 200, request.uri().getPath().endsWith(".pem") ? pem : "confirmed"));
}));
routes.setQInstance(instance);
server = Javalin.create(routes::acceptJavalinConfig).start(0);
client = HttpClient.newHttpClient();
}
/*******************************************************************************
** Release every server, connection and thread-local test context.
*******************************************************************************/
@AfterEach
void teardown()
{
server.stop();
client.close();
EmailRuntime.setClock(null);
QContext.clear();
}
/*******************************************************************************
** HTTP request helper, with no application code bypass.
*******************************************************************************/
private HttpResponse<String> request(String path, String method, String body, String... headers) throws Exception
{
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + BASE + path));
if(headers.length > 0)
{
builder.headers(headers);
}
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
}
/*******************************************************************************
** Fixed Basic auth used only by synthetic Postmark requests.
*******************************************************************************/
private static String auth()
{
return ("Basic " + Base64.getEncoder().encodeToString(("postmark:" + SECRET).getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** All status codes and streaming size caps before any authentication lookup.
*******************************************************************************/
@Test
void postmarkStatusesAndDedup() throws Exception
{
QRecord provider = provider("POSTMARK");
String body = resource("/webhooks/postmark-delivery.json");
assertThat(request("/postmark/postmark", "POST", body, "Authorization", auth()).body()).contains("received");
assertThat(request("/postmark/postmark", "POST", body, "Authorization", auth()).body()).contains("duplicate");
assertThat(H2Support.all(EmailTables.INBOX)).hasSize(1);
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
assertThat(request("/postmark/postmark", "POST", body).statusCode()).isEqualTo(401);
assertThat(request("/postmark/postmark", "POST", "invalid", "Authorization", auth()).statusCode()).isEqualTo(401);
assertThat(request("/mailgun/postmark", "POST", body).statusCode()).isEqualTo(404);
assertThat(request("/postmark/missing", "POST", body).statusCode()).isEqualTo(404);
assertThat(request("/postmark/postmark", "POST", "x".repeat(1024 * 1024 + 1)).statusCode()).isEqualTo(413);
var chunked = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + BASE + "/postmark/postmark"))
.POST(HttpRequest.BodyPublishers.ofInputStream(() -> new java.io.ByteArrayInputStream(new byte[1024 * 1024 + 1]))).build();
assertThat(client.send(chunked, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(413);
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(request("/postmark/postmark", "POST", body).statusCode()).isEqualTo(503);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false).withValue("isDefault", false));
assertThat(request("/postmark/postmark", "POST", body).statusCode()).isEqualTo(404);
}
/*******************************************************************************
** Missing per-provider verification configuration is explicitly retryable.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"SES", "SENDGRID", "MAILGUN"})
void missingSecretOrTopic(String kind) throws Exception
{
QRecord provider = provider(kind);
if("SES".equals(kind))
{
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("snsTopicArn", null));
}
EmailRuntime.config().withSecretResolver(name -> null);
assertThat(request("/" + kind.toLowerCase() + "/" + kind.toLowerCase(), "POST", "{}").statusCode()).isEqualTo(503);
}
/*******************************************************************************
** DB failure is 503, and idempotent registration never adds duplicate routes.
*******************************************************************************/
@Test
void unavailableStoreAndRegistration() throws Exception
{
provider("POSTMARK");
EmailWebhookRouteProvider.register(instance);
EmailWebhookRouteProvider.register(instance);
assertThat(QJavalinMetaData.ofOrWithNew(instance).getAdditionalRouteProviderReferences()).hasSize(1);
var removed = instance.getTables().remove(EmailTables.INBOX);
try
{
assertThat(request("/postmark/postmark", "POST", resource("/webhooks/postmark-delivery.json"), "Authorization", auth()).statusCode()).isEqualTo(503);
}
finally
{
instance.addTable(removed);
}
}
/*******************************************************************************
** SNS verifies before confirming, with a durable ignored control message.
*******************************************************************************/
@Test
void snsNotificationConfirmationAndTopicMismatch() throws Exception
{
provider("SES");
EmailRuntime.setClock(Clock.fixed(certificateTime(), ZoneOffset.UTC));
JSONObject payload = signSns(new JSONObject(resource("/webhooks/ses-bounce.json")));
assertThat(request("/ses/ses", "POST", payload.toString()).statusCode()).isEqualTo(200);
assertThat(request("/ses/ses", "POST", payload.toString()).body()).contains("duplicate");
payload.put("MessageId", "subscription").put("Type", "SubscriptionConfirmation").put("Token", "test-token")
.put("SubscribeURL", "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=test");
signSns(payload);
assertThat(request("/ses/ses", "POST", payload.toString()).statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.INBOX).get(1).getValueString("status")).isEqualTo("IGNORED");
assertThat(fetched).anyMatch(url -> url.contains("ConfirmSubscription"));
assertThat(request("/ses/ses", "POST", signSns(payload.put("SubscribeURL", "http://evil.example/")).toString()).statusCode()).isEqualTo(401);
assertThat(request("/ses/ses", "POST", signSns(payload.put("TopicArn", TOPIC + "other")).toString()).statusCode()).isEqualTo(401);
}
/*******************************************************************************
** Signed raw SendGrid body is accepted and tampering leaves no extra receipt.
*******************************************************************************/
@Test
void sendgridSignedBatch() throws Exception
{
provider("SENDGRID");
var keys = KeyPairGenerator.getInstance("EC").generateKeyPair();
EmailRuntime.config().withSecretResolver(name -> Base64.getEncoder().encodeToString(keys.getPublic().getEncoded()));
String body = resource("/webhooks/sendgrid-batch.json");
String timestamp = Long.toString(NOW.getEpochSecond());
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(keys.getPrivate());
signer.update((timestamp + body).getBytes(StandardCharsets.UTF_8));
String signature = Base64.getEncoder().encodeToString(signer.sign());
String[] headers = {"X-Twilio-Email-Event-Webhook-Signature", signature, "X-Twilio-Email-Event-Webhook-Timestamp", timestamp};
assertThat(request("/sendgrid/sendgrid", "POST", body, headers).statusCode()).isEqualTo(200);
assertThat(request("/sendgrid/sendgrid", "POST", body + " ", headers).statusCode()).isEqualTo(401);
assertThat(request("/sendgrid/sendgrid", "POST", body, headers).body()).contains("duplicate");
}
/*******************************************************************************
** Mailgun replay is 401, but a new signed nonce with an old event id is 200.
*******************************************************************************/
@Test
void mailgunReplayAndDuplicateEvent() throws Exception
{
provider("MAILGUN");
JSONObject payload = signMailgun(new JSONObject(resource("/webhooks/mailgun-failed.json")), "nonce-one", NOW);
assertThat(request("/mailgun/mailgun", "POST", payload.toString()).body()).contains("received");
assertThat(request("/mailgun/mailgun", "POST", payload.toString()).statusCode()).isEqualTo(401);
signMailgun(payload, "nonce-two", NOW);
assertThat(request("/mailgun/mailgun", "POST", payload.toString()).body()).contains("duplicate");
signMailgun(payload, "stale", NOW.minusSeconds(301));
assertThat(request("/mailgun/mailgun", "POST", payload.toString()).statusCode()).isEqualTo(401);
}
/*******************************************************************************
** Concurrent receipt dedup is durable across separate HTTP worker threads.
*******************************************************************************/
@Test
void concurrentDuplicates() throws Exception
{
provider("POSTMARK");
String body = resource("/webhooks/postmark-bounce.json");
try(var workers = Executors.newFixedThreadPool(2))
{
var first = workers.submit(() -> request("/postmark/postmark", "POST", body, "Authorization", auth()));
var second = workers.submit(() -> request("/postmark/postmark", "POST", body, "Authorization", auth()));
assertThat(first.get(20, TimeUnit.SECONDS).statusCode()).isEqualTo(200);
assertThat(second.get(20, TimeUnit.SECONDS).statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.INBOX)).hasSize(1);
}
}
/*******************************************************************************
** GET never opts out, page content is escaped and one-click returns no body.
*******************************************************************************/
@Test
void unsubscribeGetAndBothPosts() throws Exception
{
QRecord message = message(provider("POSTMARK"));
QRecord brand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "brand").withValue("label", "<script>Brand & Co</script>"));
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("brandId", brand.getValue("id")));
String token = token(message);
var get = request("/unsubscribe/" + token, "GET", null);
assertThat(get.statusCode()).isEqualTo(200);
assertThat(get.body()).contains("&lt;script&gt;Brand &amp; Co&lt;/script&gt;", "Unsubscribe from Marketing", "color-scheme:light dark", "prefers-color-scheme:dark", "<form method=\"post\">")
.doesNotContain("<script>", "recipient@example.com", "http://", "https://");
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
assertThat(request("/unsubscribe/" + token, "POST", "anything").statusCode()).isEqualTo(400);
var click = request("/unsubscribe/" + token, "POST", "List-Unsubscribe=One-Click", "Content-Type", "application/x-www-form-urlencoded");
assertThat(click.statusCode()).isEqualTo(200);
assertThat(click.body()).isEmpty();
assertThat(request("/unsubscribe/" + token, "POST", "confirm=unsubscribe").body()).contains("You are unsubscribed").doesNotContain("<form");
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
assertThat(H2Support.all(EmailTables.SUPPRESSION).getFirst().getValueString("scope")).isEqualTo("MARKETING");
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status")).isEqualTo("SENT");
assertThat(request("/unsubscribe/" + token, "POST", "x".repeat(1024 * 1024 + 1)).statusCode()).isEqualTo(413);
}
/*******************************************************************************
** Real enqueue uses the existing signer and preserves the ad-hoc brand label.
*******************************************************************************/
@Test
void queuedAdHocBrandAndSigner() throws Exception
{
provider("POSTMARK");
QRecord brand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "chosen-brand").withValue("label", "Chosen Brand"));
var result = com.kingsrook.qbits.email.outbox.EmailService.send(new com.kingsrook.qbits.email.outbox.EmailRequest()
.withTo("recipient@example.com").withSubject("Notice").withMarkdownBody("A notice").withBrandName("chosen-brand")
.withCategory(com.kingsrook.qbits.email.outbox.EmailCategory.NOTIFICATION));
assertThat(result.status()).isEqualTo(com.kingsrook.qbits.email.outbox.EmailSendResult.Status.QUEUED);
QRecord stored = H2Support.all(EmailTables.MESSAGE).getFirst();
assertThat(stored.getValueInteger("brandId")).isEqualTo(brand.getValueInteger("id"));
String url = stored.getValueString("unsubscribeUrl");
String path = "/unsubscribe/" + url.substring(url.lastIndexOf('/') + 1);
assertThat(request(path, "GET", null).body()).contains("Notification email from Chosen Brand?");
assertThat(request(path, "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.SUPPRESSION).getFirst().getValueString("scope")).isEqualTo("NOTIFICATION");
}
/*******************************************************************************
** One signed mailbox is suppressed, while repeat POST restores its opt-out.
*******************************************************************************/
@Test
void unsubscribeSingleRecipientAndRepeatedSuppression() throws Exception
{
QRecord provider = provider("POSTMARK");
QRecord message = message(provider);
message.withValue("toAddresses", "\"Recipient, One\" <Recipient@example.com>").withValue("category", "NOTIFICATION");
SystemWrites.updateOne(EmailTables.MESSAGE, message);
QRecord other = message(provider);
SystemWrites.updateOne(EmailTables.MESSAGE, other.withValue("toAddresses", "other@example.com"));
String signed = token(message);
assertThat(EmailUnsubscribeLinks.verify(signed).getString("addressHash"))
.isEqualTo(WebhookVerification.hash("recipient@example.com".getBytes(StandardCharsets.UTF_8)));
String path = "/unsubscribe/" + signed;
assertThat(request(path, "POST", "confirm=unsubscribe").statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1).allSatisfy(row ->
{
assertThat(row.getValueString("scope")).isEqualTo("NOTIFICATION");
assertThat(row.getValueString("address")).isEqualTo("recipient@example.com");
});
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
SystemWrites.delete(EmailTables.SUPPRESSION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter());
assertThat(request(path, "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
assertThat(H2Support.all(EmailTables.EVENT)).hasSize(1);
}
/*******************************************************************************
** Legacy single-recipient links keep working; legacy multi-recipient links fail closed.
*******************************************************************************/
@Test
void legacyUnsubscribeRequiresSingleRecipient() throws Exception
{
QRecord message = message(provider("POSTMARK"));
String safe = legacyToken(message);
assertThat(request("/unsubscribe/" + safe, "GET", null).statusCode()).isEqualTo(200);
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
{
QRecord changed = new QRecord().withValue("id", message.getValue("id")).withValue("toAddresses", "recipient@example.com")
.withValue("ccAddresses", null).withValue("bccAddresses", null)
.withValue(field, "toAddresses".equals(field) ? "recipient@example.com, other@example.com" : "other@example.com");
SystemWrites.updateOne(EmailTables.MESSAGE, changed);
changed.withValue("category", "MARKETING");
String legacy = legacyToken(changed);
for(String signed : List.of(safe, legacy))
{
assertThat(request("/unsubscribe/" + signed, "GET", null).statusCode()).isEqualTo(404);
assertThat(request("/unsubscribe/" + signed, "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(404);
}
}
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
assertThat(H2Support.all(EmailTables.EVENT)).isEmpty();
SystemWrites.updateOne(EmailTables.MESSAGE, message.withValue("ccAddresses", null).withValue("bccAddresses", null));
assertThat(request("/unsubscribe/" + safe, "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(200);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
}
/*******************************************************************************
** Reproduce the old signature without relying on the current signer's validation.
*******************************************************************************/
private static String legacyToken(QRecord message) throws Exception
{
String envelope = message.getValueString("toAddresses") + "," + message.getValueString("ccAddresses") + "," + message.getValueString("bccAddresses");
JSONObject payload = new JSONObject().put("messageId", message.getValueString("id")).put("category", message.getValueString("category"))
.put("addressHash", WebhookVerification.hash(envelope.getBytes(StandardCharsets.UTF_8)));
String signed = "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8));
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return (signed + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(signed.getBytes(StandardCharsets.UTF_8))));
}
/*******************************************************************************
** Signer and receiver reject non-opt-out categories, including valid HMACs.
*******************************************************************************/
@Test
void invalidAndTransactionalTokens() throws Exception
{
QRecord message = message(provider("POSTMARK"));
String token = token(message);
assertThat(request("/unsubscribe/bad-token", "GET", null).statusCode()).isEqualTo(404);
assertThat(request("/unsubscribe/" + token + "x", "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(404);
for(String category : List.of("TRANSACTIONAL", "SECURITY"))
{
assertThat(EmailUnsubscribeLinks.create("1", "recipient@example.com", category)).isNull();
JSONObject data = EmailUnsubscribeLinks.verify(token).put("category", category);
String signed = "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(data.toString().getBytes(StandardCharsets.UTF_8));
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String forged = signed + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)));
assertThat(request("/unsubscribe/" + forged, "POST", "List-Unsubscribe=One-Click").statusCode()).isEqualTo(404);
}
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("toAddresses", "changed@example.com"));
assertThat(request("/unsubscribe/" + token, "GET", null).statusCode()).isEqualTo(404);
assertThat(H2Support.all(EmailTables.SUPPRESSION)).isEmpty();
}
/*******************************************************************************
** Logs at normal operational levels never contain addresses or payload bodies.
*******************************************************************************/
@Test
void logsContainNoAddresses() throws Exception
{
QRecord provider = provider("POSTMARK");
message(provider);
List<String> logs = new java.util.concurrent.CopyOnWriteArrayList<>();
var capture = new org.apache.logging.log4j.core.appender.AbstractAppender("tracking-privacy", null, null, true, null)
{
@Override
public void append(org.apache.logging.log4j.core.LogEvent event)
{
// Match log4j2-test.xml: core's explicitly OFF diagnostics are not output.
if(event.getLevel() == org.apache.logging.log4j.Level.OFF)
{
return;
}
logs.add(event.getMessage().getFormattedMessage());
if(event.getThrown() != null)
{
logs.add(event.getThrown().toString());
}
}
};
capture.start();
var context = (org.apache.logging.log4j.core.LoggerContext) org.apache.logging.log4j.LogManager.getContext(false);
var root = context.getConfiguration().getRootLogger();
var previousLevel = root.getLevel();
root.addAppender(capture, org.apache.logging.log4j.Level.ALL, null);
root.setLevel(org.apache.logging.log4j.Level.INFO);
context.updateLoggers();
try
{
String body = resource("/webhooks/postmark-bounce.json");
assertThat(request("/postmark/postmark", "POST", body, "Authorization", auth()).statusCode()).isEqualTo(200);
assertThat(request("/postmark/postmark", "POST", body, "Authorization", auth()).statusCode()).isEqualTo(200);
assertThat(request("/postmark/postmark", "POST", body).statusCode()).isEqualTo(401);
new EmailInboxProcessor().runOnce();
String broken = new JSONObject(body).put("ID", 999).put("MessageID", "missing").toString();
assertThat(request("/postmark/postmark", "POST", broken, "Authorization", auth()).statusCode()).isEqualTo(200);
new EmailInboxProcessor().runOnce();
assertThat(String.join("\n", logs)).doesNotContain("recipient@example.com", "sender@example.com", SECRET, "RecordType", "550 mailbox");
assertThat(H2Support.all(EmailTables.INBOX).getLast().getValueString("lastError")).doesNotContain("@", "missing");
}
finally
{
root.removeAppender(capture.getName());
root.setLevel(previousLevel);
context.updateLoggers();
capture.stop();
}
}
/*******************************************************************************
** Path validation excludes UI API routes and route metacharacters.
*******************************************************************************/
@Test
void routeConfigurationValidation()
{
for(String path : List.of("relative", "/qqq/v1", "/qqq/v1/email", "/hooks/{param}", "//hooks", "/hooks/"))
{
List<String> errors = new ArrayList<>();
EmailRuntime.config().withWebhookBasePath(path).validate(instance, errors);
assertThat(errors).anyMatch(error -> error.contains("webhookBasePath"));
}
List<String> errors = new ArrayList<>();
EmailRuntime.config().withWebhookBasePath("/qqq/hooks/email").validate(instance, errors);
assertThat(errors).isEmpty();
}
/*******************************************************************************
** Both system tables are readable by viewers and reject direct user writes.
*******************************************************************************/
@Test
void systemTablePermissions() throws Exception
{
QContext.getQSession().setPermissions(java.util.Set.of("email.view"));
var checker = new com.kingsrook.qbits.email.permissions.EmailPermissionChecker();
for(String table : List.of(EmailTables.EVENT, EmailTables.INBOX))
{
var metadata = instance.getTable(table);
checker.checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(table), metadata);
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(table), metadata))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(table), metadata))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(table), metadata))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
}
assertThat(instance.getTable(EmailTables.PROVIDER).getSections()).anyMatch(section -> (EmailTables.INBOX + "_children").equals(section.getWidgetName()));
assertThat(instance.getTable(EmailTables.MESSAGE).getSections()).anyMatch(section -> (EmailTables.EVENT + "_children").equals(section.getWidgetName()));
}
/*******************************************************************************
** Sign the same single To mailbox as enqueue.
*******************************************************************************/
private static String token(QRecord message)
{
String url = EmailUnsubscribeLinks.create(message.getValueString("id"), message.getValueString("toAddresses"), message.getValueString("category"));
assertThat(url).startsWith("https://app.example.com" + BASE + "/unsubscribe/");
return (url.substring(url.lastIndexOf('/') + 1));
}
}
@@ -0,0 +1,248 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.net.URI;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.net.ssl.SSLSession;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Synthetic provider and crypto fixtures, always non-production.
*******************************************************************************/
public final class TrackingTestSupport
{
public static final Instant NOW = Instant.parse("2026-10-13T12:05:00Z");
public static final String SECRET = "test-only-webhook-secret";
public static final String TOPIC = "arn:aws:sns:us-east-1:123456789012:email-events";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TrackingTestSupport()
{
}
/*******************************************************************************
** Load a synthetic JSON or test-only PEM fixture from the classpath.
*******************************************************************************/
public static String resource(String name) throws Exception
{
try(var stream = TrackingTestSupport.class.getResourceAsStream(name))
{
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
/*******************************************************************************
** Configure a fully valid provider row without resolving its sending key.
*******************************************************************************/
public static QRecord provider(String kind) throws Exception
{
return (H2Support.insert(EmailTables.PROVIDER, new QRecord().withValue("name", kind.toLowerCase(java.util.Locale.ROOT))
.withValue("kind", kind).withValue("enabled", true).withValue("isDefault", H2Support.all(EmailTables.PROVIDER).isEmpty())
.withValue("fromAddress", "sender@example.com").withValue("sendingDomain", "example.com").withValue("region", "SES".equals(kind) ? "us-east-1" : "US")
.withValue("secretEnvName", switch(kind)
{
case "POSTMARK" -> "POSTMARK_SERVER_TOKEN";
case "SENDGRID" -> "SENDGRID_API_KEY";
case "MAILGUN" -> "MAILGUN_API_KEY";
default -> null;
})
.withValue("webhookSecretEnvName", switch(kind)
{
case "POSTMARK" -> "POSTMARK_WEBHOOK_PASSWORD";
case "SENDGRID" -> "SENDGRID_WEBHOOK_PUBLIC_KEY";
case "MAILGUN" -> "MAILGUN_WEBHOOK_SIGNING_KEY";
default -> null;
})
.withValue("snsTopicArn", TOPIC)));
}
/*******************************************************************************
** Persist an accepted outbox row through the same authorized scope as delivery.
*******************************************************************************/
public static QRecord message(QRecord provider) throws Exception
{
return (SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("providerId", provider.getValue("id"))
.withValue("providerMessageId", "provider-message-1").withValue("status", "SENT").withValue("category", "MARKETING")
.withValue("toAddresses", "recipient@example.com").withValue("sentDate", NOW.minusSeconds(600))));
}
/*******************************************************************************
** Test certificate controls the verification date independent of execution day.
*******************************************************************************/
public static Instant certificateTime() throws Exception
{
try(var stream = TrackingTestSupport.class.getResourceAsStream("/sns-test/test-only-certificate.pem"))
{
var cert = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(stream);
return (cert.getNotBefore().toInstant().plusSeconds(60));
}
}
/*******************************************************************************
** Sign independently using AWS's required order, not the implementation helper.
*******************************************************************************/
public static JSONObject signSns(JSONObject payload) throws Exception
{
return (signSns(payload, certificateTime()));
}
/*******************************************************************************
** Sign with an explicit envelope Timestamp (the verifier enforces freshness).
*******************************************************************************/
public static JSONObject signSns(JSONObject payload, Instant timestamp) throws Exception
{
payload.put("Timestamp", timestamp.toString());
String pem = resource("/sns-test/test-only-private-key.pem").replaceAll("-----[^-]+-----", "").replaceAll("\\s", "");
PrivateKey key = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem)));
List<String> fields = "Notification".equals(payload.getString("Type")) ? List.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type")
: List.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type");
StringBuilder canonical = new StringBuilder();
for(String field : fields)
{
if(payload.has(field))
{
canonical.append(field).append('\n').append(payload.getString(field)).append('\n');
}
}
Signature signer = Signature.getInstance("1".equals(payload.getString("SignatureVersion")) ? "SHA1withRSA" : "SHA256withRSA");
signer.initSign(key);
signer.update(canonical.toString().getBytes(StandardCharsets.UTF_8));
return (payload.put("Signature", Base64.getEncoder().encodeToString(signer.sign())));
}
/*******************************************************************************
** Sign Mailgun's timestamp and nonce with a clearly test-only HMAC key.
*******************************************************************************/
public static JSONObject signMailgun(JSONObject body, String nonce, Instant now) throws Exception
{
String timestamp = Long.toString(now.getEpochSecond());
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
body.put("signature", new JSONObject().put("timestamp", timestamp).put("token", nonce)
.put("signature", java.util.HexFormat.of().formatHex(mac.doFinal((timestamp + nonce).getBytes(StandardCharsets.UTF_8)))));
return (body);
}
/*******************************************************************************
** Memory-only HTTP response for the existing EmailHttp seam.
*******************************************************************************/
public static HttpResponse<String> response(HttpRequest request, int status, String body)
{
return (new HttpResponse<>()
{
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public int statusCode()
{
return (status);
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public HttpRequest request()
{
return (request);
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public Optional<HttpResponse<String>> previousResponse()
{
return (Optional.empty());
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public HttpHeaders headers()
{
return (HttpHeaders.of(Map.of(), (a, b) -> true));
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public String body()
{
return (body);
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public Optional<SSLSession> sslSession()
{
return (Optional.empty());
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public URI uri()
{
return (request.uri());
}
/*******************************************************************************
** Test adapter implementation.
*******************************************************************************/
@Override
public java.net.http.HttpClient.Version version()
{
return (java.net.http.HttpClient.Version.HTTP_1_1);
}
});
}
}
@@ -0,0 +1,231 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.*;
import static org.assertj.core.api.Assertions.*;
/*******************************************************************************
** Crypto boundaries use genuine signatures and a public test-only RSA fixture.
*******************************************************************************/
class WebhookVerifiersTest
{
/*******************************************************************************
** Postmark's fixed user and constant-time credential verification.
*******************************************************************************/
@Test
void postmarkValidTamperedMissing()
{
var verifier = new PostmarkVerifier();
String auth = "Basic " + Base64.getEncoder().encodeToString(("postmark:" + SECRET).getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(auth, SECRET)).isTrue();
assertThat(verifier.verify(auth, "wrong")).isFalse();
assertThat(verifier.verify(auth, null)).isFalse();
assertThat(verifier.verify(auth, " ")).isFalse();
assertThat(verifier.verify(null, SECRET)).isFalse();
assertThat(verifier.verify("Bearer abc", SECRET)).isFalse();
assertThat(verifier.verify("Basic ?", SECRET)).isFalse();
assertThat(verifier.verify("Basic " + Base64.getEncoder().encodeToString(("other:" + SECRET).getBytes(StandardCharsets.UTF_8)), SECRET)).isFalse();
}
/*******************************************************************************
** Genuine ECDSA validates unchanged bytes, timestamp freshness and DER key.
*******************************************************************************/
@Test
void sendgridValidTamperedStaleMissing() throws Exception
{
var keys = KeyPairGenerator.getInstance("EC").generateKeyPair();
String publicKey = Base64.getEncoder().encodeToString(keys.getPublic().getEncoded());
byte[] body = "[{\"event\":\"delivered\"}]".getBytes(StandardCharsets.UTF_8);
String timestamp = Long.toString(NOW.getEpochSecond());
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(keys.getPrivate());
signer.update(timestamp.getBytes(StandardCharsets.UTF_8));
signer.update(body);
String signed = Base64.getEncoder().encodeToString(signer.sign());
var verifier = new SendGridVerifier();
assertThat(verifier.verify(body, signed, timestamp, publicKey, NOW)).isTrue();
assertThat(verifier.verify("tampered".getBytes(StandardCharsets.UTF_8), signed, timestamp, publicKey, NOW)).isFalse();
assertThat(verifier.verify(body, signed, timestamp, publicKey, NOW.plusSeconds(301))).isFalse();
assertThat(verifier.verify(body, signed, timestamp, publicKey, NOW.minusSeconds(301))).isFalse();
assertThat(verifier.verify(body, signed, "9223372036854775807", publicKey, NOW)).isFalse();
assertThat(verifier.verify(body, signed, timestamp, null, NOW)).isFalse();
assertThat(verifier.verify(body, null, timestamp, publicKey, NOW)).isFalse();
assertThat(verifier.verify(body, "?", timestamp, publicKey, NOW)).isFalse();
assertThat(verifier.verify(body, signed, timestamp, "?", NOW)).isFalse();
}
/*******************************************************************************
** Mailgun validates the signed nonce, not its unsigned event body.
*******************************************************************************/
@Test
void mailgunValidTamperedStaleMissing() throws Exception
{
var verifier = new MailgunVerifier();
JSONObject data = signMailgun(new JSONObject(), "test-nonce", NOW);
assertThat(verifier.verify(data, SECRET, NOW)).isTrue();
assertThat(verifier.verify(data, SECRET, NOW.plusSeconds(301))).isFalse();
assertThat(verifier.verify(data, SECRET, NOW.minusSeconds(301))).isFalse();
assertThat(verifier.verify(data, null, NOW)).isFalse();
assertThat(verifier.verify(data, "wrong", NOW)).isFalse();
data.getJSONObject("signature").put("token", "changed");
assertThat(verifier.verify(data, SECRET, NOW)).isFalse();
assertThat(verifier.verify(new JSONObject(), SECRET, NOW)).isFalse();
data.getJSONObject("signature").put("token", "");
assertThat(verifier.verify(data, SECRET, NOW)).isFalse();
}
/*******************************************************************************
** Both SNS algorithms, optional Subject and certificate caching.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"1", "2"})
void snsSignatureAndCache(String version) throws Exception
{
String pem = resource("/sns-test/test-only-certificate.pem");
AtomicInteger requests = new AtomicInteger();
var verifier = new SnsVerifier(request ->
{
requests.incrementAndGet();
return (response(request, 200, pem));
});
JSONObject data = signSns(new JSONObject(resource("/webhooks/ses-bounce.json")).put("SignatureVersion", version));
Instant now = certificateTime();
assertThat(verifier.verify(data, TOPIC, now)).isTrue();
assertThat(verifier.verify(signSns(data.put("Subject", "Test subject")), TOPIC, now)).isTrue();
assertThat(requests.get()).isEqualTo(1);
assertThat(verifier.verify(data, TOPIC + "other", now)).isFalse();
assertThat(verifier.verify(data, null, now)).isFalse();
data.put("Message", "tampered");
assertThat(verifier.verify(data, TOPIC, now)).isFalse();
data.put("SignatureVersion", "3");
assertThat(verifier.verify(data, TOPIC, now)).isFalse();
}
/*******************************************************************************
** Notifications outside the one hour past / five minute future window are refused
** before any certificate fetch.
*******************************************************************************/
@Test
void snsTimestampWindow() throws Exception
{
AtomicInteger requests = new AtomicInteger();
String pem = resource("/sns-test/test-only-certificate.pem");
var verifier = new SnsVerifier(request ->
{
requests.incrementAndGet();
return (response(request, 200, pem));
});
Instant now = certificateTime();
JSONObject base = new JSONObject(resource("/webhooks/ses-bounce.json"));
assertThat(verifier.verify(signSns(new JSONObject(base.toString()), now.minusSeconds(3601)), TOPIC, now)).isFalse();
assertThat(verifier.verify(signSns(new JSONObject(base.toString()), now.plusSeconds(301)), TOPIC, now)).isFalse();
assertThat(requests.get()).isZero();
assertThat(verifier.verify(signSns(new JSONObject(base.toString()), now.minusSeconds(3599)), TOPIC, now)).isTrue();
assertThat(verifier.verify(signSns(new JSONObject(base.toString()), now.plusSeconds(299)), TOPIC, now)).isTrue();
assertThat(verifier.verify(signSns(new JSONObject(base.toString())).put("Timestamp", "yesterday"), TOPIC, now)).isFalse();
}
/*******************************************************************************
** A failed certificate fetch is remembered per URL for five minutes.
*******************************************************************************/
@Test
void snsCachesCertificateFetchFailures() throws Exception
{
AtomicInteger requests = new AtomicInteger();
var verifier = new SnsVerifier(request ->
{
requests.incrementAndGet();
return (response(request, 503, "down"));
});
Instant now = certificateTime();
JSONObject data = signSns(new JSONObject(resource("/webhooks/ses-bounce.json")));
assertThatThrownBy(() -> verifier.verify(data, TOPIC, now)).isInstanceOf(java.io.IOException.class);
assertThatThrownBy(() -> verifier.verify(data, TOPIC, now.plusSeconds(299))).isInstanceOf(java.io.IOException.class);
assertThat(requests.get()).isEqualTo(1);
assertThatThrownBy(() -> verifier.verify(signSns(new JSONObject(data.toString()), now.plusSeconds(300)), TOPIC, now.plusSeconds(300))).isInstanceOf(java.io.IOException.class);
assertThat(requests.get()).isEqualTo(2);
}
/*******************************************************************************
** Strict AWS URL host, scheme, path and authority before network access.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"http://sns.us-east-1.amazonaws.com/cert.pem", "https://evil.example/cert.pem", "https://sns.us-east-1.amazonaws.com/cert.txt",
"https://sns.us-east-1.amazonaws.com.evil.example/cert.pem", "https://user@sns.us-east-1.amazonaws.com/cert.pem", "https://sns.us-east-1.amazonaws.com:8443/cert.pem",
"https://sns.us-east-1.amazonaws.com/cert.pem?x=1", "not a URI"})
void snsRejectsCertificateUrls(String url) throws Exception
{
var verifier = new SnsVerifier(request ->
{
throw (new AssertionError("Must not fetch untrusted URL"));
});
JSONObject data = new JSONObject(resource("/webhooks/ses-bounce.json")).put("SigningCertURL", url);
assertThat(verifier.verify(data, TOPIC, certificateTime())).isFalse();
}
/*******************************************************************************
** Confirmations use AWS field order and only verified AWS URLs are fetched.
*******************************************************************************/
@Test
void snsConfirmationsAndFailures() throws Exception
{
String pem = resource("/sns-test/test-only-certificate.pem");
AtomicInteger confirms = new AtomicInteger();
var verifier = new SnsVerifier(request ->
{
if(request.uri().getPath().endsWith(".pem"))
{
return (response(request, 200, pem));
}
confirms.incrementAndGet();
return (response(request, 200, "confirmed"));
});
for(String type : List.of("SubscriptionConfirmation", "UnsubscribeConfirmation"))
{
JSONObject data = signSns(new JSONObject(resource("/webhooks/ses-bounce.json")).put("Type", type)
.put("SubscribeURL", "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=test")
.put("Token", "test-token"));
assertThat(verifier.verify(data, TOPIC, certificateTime())).isTrue();
verifier.confirm(data);
assertThatThrownBy(() -> verifier.confirm(data.put("SubscribeURL", "https://evil.example/"))).isInstanceOf(IllegalArgumentException.class);
}
assertThat(confirms.get()).isEqualTo(2);
assertThat(SnsVerifier.allowedUrl("https://sns.cn-north-1.amazonaws.com.cn/test.pem", true)).isTrue();
JSONObject data = signSns(new JSONObject(resource("/webhooks/ses-bounce.json")));
assertThat(new SnsVerifier(request -> response(request, 200, "bad cert")).verify(data, TOPIC, certificateTime())).isFalse();
assertThatThrownBy(() -> new SnsVerifier(request -> response(request, 503, "error")).verify(data, TOPIC, certificateTime())).isInstanceOf(java.io.IOException.class);
assertThat(verifier.verify(data, TOPIC, Instant.parse("2000-01-01T00:00:00Z"))).isFalse();
data.put("Type", "Unsupported");
assertThat(verifier.verify(data, TOPIC, certificateTime())).isFalse();
}
}
@@ -0,0 +1,756 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.transport;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import javax.net.ssl.SSLSession;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.sun.net.httpserver.HttpServer;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Provider contracts, safe failures and database-to-HTTP delivery without vendors.
*******************************************************************************/
class EmailHttpTransportsTest
{
private static final String SECRET = "fake-sensitive-token";
private static final String ECHO = "private-recipient@example.com " + SECRET;
/*******************************************************************************
** A deterministic response without any network or third-party test dependency.
*******************************************************************************/
private record Response(int statusCode, HttpHeaders headers, String body) implements HttpResponse<String>
{
/*******************************************************************************
** Unused request metadata.
*******************************************************************************/
@Override
public HttpRequest request()
{
return (null);
}
/*******************************************************************************
** Redirects are never followed.
*******************************************************************************/
@Override
public Optional<HttpResponse<String>> previousResponse()
{
return (Optional.empty());
}
/*******************************************************************************
** No TLS session is needed for the fake.
*******************************************************************************/
@Override
public Optional<SSLSession> sslSession()
{
return (Optional.empty());
}
/*******************************************************************************
** No response URL is consumed by a transport.
*******************************************************************************/
@Override
public URI uri()
{
return (URI.create("https://example.com"));
}
/*******************************************************************************
** Test protocol version.
*******************************************************************************/
@Override
public HttpClient.Version version()
{
return (HttpClient.Version.HTTP_1_1);
}
}
/*******************************************************************************
** Build an HTTP response with case-insensitive headers.
*******************************************************************************/
private static Response response(int status, String body, Map<String, List<String>> headers)
{
return (new Response(status, HttpHeaders.of(headers, (name, value) -> true), body));
}
/*******************************************************************************
** The same envelope exercises alternatives, display names and blind recipients.
*******************************************************************************/
private static EmailEnvelope envelope()
{
return (new EmailEnvelope().withMessageId("42").withFrom("Sender <sender@example.com>")
.withTo(List.of("Person <to@example.com>")).withCc(List.of("cc@example.com")).withBcc(List.of("bcc@example.com"))
.withReplyTo("Replies <reply@example.com>").withSubject("Hello \u00e9").withTextBody("Plain \u00e9").withHtmlBody("<b>Hello \u00e9</b>")
.withHeaders(Map.of("List-Unsubscribe", "<https://example.com/unsubscribe>",
"List-Unsubscribe-Post", "List-Unsubscribe=One-Click", "X-Custom", "kept")));
}
/*******************************************************************************
** Required row fields, using the new default allowlisted credential names.
*******************************************************************************/
private static QRecord row(String kind)
{
String env = switch(kind)
{
case "POSTMARK" -> "POSTMARK_SERVER_TOKEN";
case "SENDGRID" -> "SENDGRID_API_KEY";
case "MAILGUN" -> "MAILGUN_API_KEY";
default -> "GMAIL_APP_PASSWORD";
};
return (new QRecord().withValue("kind", kind).withValue("secretEnvName", env)
.withValue("fromAddress", "sender@example.com").withValue("sendingDomain", "example.com").withValue("region", "US"));
}
/*******************************************************************************
** Secrets are supplied only when the transport starts its attempt.
*******************************************************************************/
private static EmailProviderSettings settings(QRecord row)
{
return (new EmailProviderSettings(row, new EmailQBitConfig().withSecretResolver(name -> SECRET)));
}
/*******************************************************************************
** Select the real transport behind the fake exchange seam.
*******************************************************************************/
private static EmailTransport transport(String kind, EmailHttp http)
{
return (switch(kind)
{
case "POSTMARK" -> new PostmarkEmailTransport(http);
case "SENDGRID" -> new SendGridEmailTransport(http);
case "MAILGUN" -> new MailgunEmailTransport(http);
default -> throw new IllegalArgumentException(kind);
});
}
/*******************************************************************************
** Read an in-memory request publisher just as a client would.
*******************************************************************************/
private static byte[] body(HttpRequest request) throws Exception
{
var subscriber = HttpResponse.BodySubscribers.ofByteArray();
request.bodyPublisher().orElseThrow().subscribe(new java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer>()
{
/*******************************************************************************
** Pass demand through to the in-memory publisher.
*******************************************************************************/
@Override
public void onSubscribe(java.util.concurrent.Flow.Subscription subscription)
{
subscriber.onSubscribe(subscription);
}
/*******************************************************************************
** Request publishers emit buffers; response subscribers consume buffer lists.
*******************************************************************************/
@Override
public void onNext(java.nio.ByteBuffer buffer)
{
subscriber.onNext(List.of(buffer));
}
/*******************************************************************************
** Preserve test failures through the completion stage.
*******************************************************************************/
@Override
public void onError(Throwable error)
{
subscriber.onError(error);
}
/*******************************************************************************
** Complete the byte array.
*******************************************************************************/
@Override
public void onComplete()
{
subscriber.onComplete();
}
});
return (subscriber.getBody().toCompletableFuture().get());
}
/*******************************************************************************
** Postmark includes all rendered fields, opt-in tracking and the chosen stream.
*******************************************************************************/
@ParameterizedTest
@ValueSource(booleans = {false, true})
void postmarkRequestAndSuccess(boolean tracking) throws Exception
{
AtomicReference<HttpRequest> captured = new AtomicReference<>();
var transport = new PostmarkEmailTransport(request ->
{
captured.set(request);
return (response(200, "{\"ErrorCode\":0,\"MessageID\":\"pm-id\",\"Message\":\"" + ECHO + "\"}", Map.of()));
});
var provider = row("POSTMARK").withValue("trackOpens", tracking).withValue("trackClicks", tracking)
.withValue("messageStream", tracking ? "broadcasts" : null);
assertThat(transport.send(envelope(), settings(provider)).providerMessageId()).isEqualTo("pm-id");
HttpRequest request = captured.get();
assertThat(request.uri().toString()).isEqualTo("https://api.postmarkapp.com/email");
assertThat(request.method()).isEqualTo("POST");
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
assertThat(request.headers().firstValue("X-Postmark-Server-Token")).contains(SECRET);
assertThat(request.headers().firstValue("Content-Type")).contains("application/json");
JsonNode json = EmailHttpSupport.JSON.readTree(body(request));
assertThat(json.path("From").asText()).isEqualTo(envelope().getFrom());
assertThat(json.path("To").asText()).isEqualTo("Person <to@example.com>");
assertThat(json.path("Cc").asText()).isEqualTo("cc@example.com");
assertThat(json.path("Bcc").asText()).isEqualTo("bcc@example.com");
assertThat(json.path("ReplyTo").asText()).isEqualTo(envelope().getReplyTo());
assertThat(json.path("Subject").asText()).isEqualTo(envelope().getSubject());
assertThat(json.path("TextBody").asText()).isEqualTo(envelope().getTextBody());
assertThat(json.path("HtmlBody").asText()).isEqualTo(envelope().getHtmlBody());
assertThat(json.path("MessageStream").asText()).isEqualTo(tracking ? "broadcasts" : "outbound");
assertThat(json.path("TrackOpens").asBoolean()).isEqualTo(tracking);
assertThat(json.path("TrackLinks").asText()).isEqualTo(tracking ? "HtmlAndText" : "None");
assertThat(json.path("Headers").toString()).contains("List-Unsubscribe", "List-Unsubscribe-Post", "X-QQQ-Email-Id", "42", "Auto-Submitted");
assertThat(json.toString()).doesNotContain(SECRET);
}
/*******************************************************************************
** SendGrid filters forbidden headers case-insensitively, retaining unsubscribe.
*******************************************************************************/
@ParameterizedTest
@ValueSource(booleans = {false, true})
void sendGridRequestAndSuccess(boolean tracking) throws Exception
{
AtomicReference<HttpRequest> captured = new AtomicReference<>();
var transport = new SendGridEmailTransport(request ->
{
captured.set(request);
return (response(202, "", Map.of("x-message-id", List.of("sg-id"))));
});
Map<String, String> headers = new java.util.HashMap<>(envelope().getHeaders());
for(String reserved : List.of("X-SG-ID", "x-sg-eid", "Received", "DKIM-Signature", "Content-Type",
"Content-Transfer-Encoding", "To", "From", "Subject", "Reply-To", "CC", "BcC"))
{
headers.put(reserved, "must-be-removed");
}
assertThat(transport.send(envelope().withHeaders(headers), settings(row("SENDGRID")
.withValue("trackOpens", tracking).withValue("trackClicks", tracking))).providerMessageId()).isEqualTo("sg-id");
HttpRequest request = captured.get();
assertThat(request.uri().toString()).isEqualTo("https://api.sendgrid.com/v3/mail/send");
assertThat(request.method()).isEqualTo("POST");
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
assertThat(request.headers().firstValue("Authorization")).contains("Bearer " + SECRET);
JsonNode json = EmailHttpSupport.JSON.readTree(body(request));
assertThat(json.at("/personalizations/0/to/0/email").asText()).isEqualTo("to@example.com");
assertThat(json.at("/personalizations/0/to/0/name").asText()).isEqualTo("Person");
assertThat(json.at("/personalizations/0/cc/0/email").asText()).isEqualTo("cc@example.com");
assertThat(json.at("/personalizations/0/bcc/0/email").asText()).isEqualTo("bcc@example.com");
assertThat(json.at("/from/email").asText()).isEqualTo("sender@example.com");
assertThat(json.at("/from/name").asText()).isEqualTo("Sender");
assertThat(json.at("/reply_to/email").asText()).isEqualTo("reply@example.com");
assertThat(json.path("subject").asText()).isEqualTo(envelope().getSubject());
assertThat(json.at("/content/0/type").asText()).isEqualTo("text/plain");
assertThat(json.at("/content/0/value").asText()).isEqualTo(envelope().getTextBody());
assertThat(json.at("/content/1/type").asText()).isEqualTo("text/html");
assertThat(json.at("/content/1/value").asText()).isEqualTo(envelope().getHtmlBody());
assertThat(json.at("/custom_args/qqq_email_id").asText()).isEqualTo("42");
assertThat(json.path("headers").toString()).doesNotContain("must-be-removed").contains("List-Unsubscribe", "List-Unsubscribe-Post");
assertThat(json.at("/tracking_settings/open_tracking/enable").asBoolean()).isEqualTo(tracking);
assertThat(json.at("/tracking_settings/click_tracking/enable").asBoolean()).isEqualTo(tracking);
assertThat(json.at("/tracking_settings/click_tracking/enable_text").asBoolean()).isEqualTo(tracking);
assertThat(json.toString()).doesNotContain(SECRET);
}
/*******************************************************************************
** Multipart preserves the MIME bytes and explicit Bcc envelope destinations.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"US,false", "EU,true"})
void mailgunRequestAndSuccess(String region, boolean tracking) throws Exception
{
AtomicReference<HttpRequest> captured = new AtomicReference<>();
var transport = new MailgunEmailTransport(request ->
{
captured.set(request);
return (response(200, "{\"id\":\"<mg-id@example.com>\",\"message\":\"" + ECHO + "\"}", Map.of()));
});
assertThat(transport.send(envelope(), settings(row("MAILGUN").withValue("region", region)
.withValue("trackOpens", tracking).withValue("trackClicks", tracking))).providerMessageId()).isEqualTo("mg-id@example.com");
HttpRequest request = captured.get();
assertThat(request.uri().toString()).isEqualTo("https://api." + ("EU".equals(region) ? "eu." : "") + "mailgun.net/v3/example.com/messages.mime");
assertThat(request.method()).isEqualTo("POST");
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
assertThat(request.headers().firstValue("Authorization")).contains("Basic " + Base64.getEncoder().encodeToString(("api:" + SECRET).getBytes(StandardCharsets.UTF_8)));
String contentType = request.headers().firstValue("Content-Type").orElseThrow();
String boundary = contentType.substring(contentType.indexOf("boundary=") + 9);
String multipart = new String(body(request), StandardCharsets.UTF_8);
assertThat(multipart).startsWith("--" + boundary + "\r\n").endsWith("\r\n--" + boundary + "--\r\n");
assertThat(multipart).contains("name=\"to\"\r\n\r\nto@example.com,cc@example.com,bcc@example.com\r\n");
assertThat(multipart).contains("name=\"o:tracking-opens\"\r\n\r\n" + (tracking ? "yes" : "no") + "\r\n");
assertThat(multipart).contains("name=\"o:tracking-clicks\"\r\n\r\n" + (tracking ? "yes" : "no") + "\r\n");
assertThat(multipart).contains("name=\"v:qqq_email_id\"\r\n\r\n42\r\n", "name=\"message\"; filename=\"message.eml\"");
String mime = multipart.substring(multipart.indexOf("Content-Type: message/rfc822\r\n\r\n") + "Content-Type: message/rfc822\r\n\r\n".length());
mime = mime.substring(0, mime.lastIndexOf("\r\n--" + boundary));
assertThat(mime).doesNotContain("bcc@example.com", "Bcc:", SECRET);
MimeMessage message = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(mime.getBytes(StandardCharsets.UTF_8)));
assertThat(message.getSubject()).isEqualTo(envelope().getSubject());
assertThat(message.getHeader("X-QQQ-Email-Id", null)).isEqualTo("42");
assertThat(message.getHeader("List-Unsubscribe-Post", null)).isEqualTo("List-Unsubscribe=One-Click");
assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.CC)[0].toString()).isEqualTo("cc@example.com");
var alternatives = (jakarta.mail.Multipart) message.getContent();
assertThat(alternatives.getBodyPart(0).getContent().toString()).isEqualTo(envelope().getTextBody());
assertThat(alternatives.getBodyPart(1).getContent().toString()).isEqualTo(envelope().getHtmlBody());
}
/*******************************************************************************
** Provider codes are numeric and never include echoed diagnostics.
*******************************************************************************/
@ParameterizedTest
@CsvSource({
"10,false,true", "300,true,false", "406,true,false", "400,false,true", "401,false,true",
"405,false,true", "412,false,true", "413,false,true", "422,false,true",
"1235,false,true", "1236,false,true", "1480,false,true", "403,true,false"
})
void postmarkErrorCodes(int code, boolean permanent, boolean providerLevel)
{
assertFailure(new PostmarkEmailTransport(request -> response(422,
"{\"ErrorCode\":" + code + ",\"Message\":\"" + ECHO + "\"}", Map.of())), "POSTMARK", permanent, providerLevel, null, "code " + code);
}
/*******************************************************************************
** HTTP status classification takes precedence over Postmark's JSON error code.
*******************************************************************************/
@ParameterizedTest
@CsvSource({
"POSTMARK,401,false,true", "POSTMARK,403,false,true", "POSTMARK,400,true,false", "POSTMARK,413,true,false",
"POSTMARK,429,false,false", "POSTMARK,500,false,false", "POSTMARK,503,false,false", "POSTMARK,302,false,true",
"SENDGRID,401,false,true", "SENDGRID,403,false,true", "SENDGRID,400,true,false", "SENDGRID,413,true,false",
"SENDGRID,429,false,false", "SENDGRID,500,false,false", "SENDGRID,503,false,false", "SENDGRID,302,false,true",
"MAILGUN,401,false,true", "MAILGUN,403,false,true", "MAILGUN,400,true,false", "MAILGUN,413,true,false",
"MAILGUN,429,false,false", "MAILGUN,500,false,false", "MAILGUN,503,false,false", "MAILGUN,302,false,true"
})
void httpFailures(String kind, int status, boolean permanent, boolean providerLevel)
{
String body = status >= 429 ? "{\"ErrorCode\":10,\"Message\":\"" + ECHO + "\"}" : ECHO;
assertFailure(transport(kind, request -> response(status, body, Map.of("Retry-After", List.of("123")))),
kind, permanent, providerLevel, Duration.ofSeconds(123), "HTTP " + status);
}
/*******************************************************************************
** Assert flags and the entire exposed exception remain free of diagnostics.
*******************************************************************************/
private static void assertFailure(EmailTransport transport, String kind, boolean permanent, boolean providerLevel, Duration retryAfter, String message)
{
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind))))
.isInstanceOfSatisfying(EmailTransportException.class, error ->
{
assertThat(error.getPermanent()).isEqualTo(permanent);
assertThat(error.getProviderLevel()).isEqualTo(providerLevel);
assertThat(error.getRetryAfter()).isEqualTo(retryAfter);
assertThat(error).hasMessageContaining(message).hasNoCause();
assertThat(error.toString()).doesNotContain(SECRET, ECHO, "private-recipient");
});
}
/*******************************************************************************
** Support delta seconds, HTTP dates, stale dates and invalid retry headers.
*******************************************************************************/
@Test
void retryAfterAndHttpPolicy() throws Exception
{
String future = ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(5).format(DateTimeFormatter.RFC_1123_DATE_TIME);
Duration delay = EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of(future))));
assertThat(delay.getSeconds()).isBetween(295L, 300L);
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of("0"))))).isEqualTo(Duration.ZERO);
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of("Sun, 06 Nov 1994 08:49:37 GMT"))))).isEqualTo(Duration.ZERO);
for(String value : List.of("-1", "99999999999999999999999", "invalid", ""))
{
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of(value))))).isNull();
}
try(HttpClient client = EmailHttp.newClient())
{
assertThat(client.followRedirects()).isEqualTo(HttpClient.Redirect.NEVER);
assertThat(client.connectTimeout()).contains(Duration.ofSeconds(10));
}
assertThatThrownBy(() -> EmailHttp.sendJdk(HttpRequest.newBuilder(URI.create("http://127.0.0.1/")).build()))
.isInstanceOf(IOException.class).hasMessage("Email HTTP requires HTTPS");
}
/*******************************************************************************
** Unknown or malformed successes must not be silently recorded as sent.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"POSTMARK", "SENDGRID", "MAILGUN"})
void invalidResponsesAndNetworkFailures(String kind) throws Exception
{
int success = "SENDGRID".equals(kind) ? 202 : 200;
for(String body : List.of(ECHO, "", "[]", "null", "{}", "{\"ErrorCode\":0}", "{\"id\":123}", "{\"ErrorCode\":\"" + SECRET + "\"}"))
{
assertFailure(transport(kind, request -> response(success, body, Map.of())), kind, false, false, null, "provider");
}
assertFailure(transport(kind, request ->
{
throw new IOException(ECHO);
}), kind, false, false, null, "temporarily unavailable");
assertFailure(transport(kind, request ->
{
throw new IllegalArgumentException(ECHO);
}), kind, false, true, null, "configuration invalid");
try
{
assertFailure(transport(kind, request ->
{
throw new InterruptedException(ECHO);
}), kind, false, false, null, "interrupted");
assertThat(Thread.currentThread().isInterrupted()).isTrue();
}
finally
{
Thread.interrupted();
}
}
/*******************************************************************************
** Required secrets resolve per attempt, never on construction or failed validation.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"POSTMARK", "SENDGRID", "MAILGUN"})
void secretResolutionAndInvalidEnvelopes(String kind) throws Exception
{
EmailHttp mustNotSend = request ->
{
throw new AssertionError("No HTTP request should have been sent");
};
var transport = transport(kind, mustNotSend);
var config = new EmailQBitConfig().withSecretResolver(name -> null);
assertThatThrownBy(() -> transport.send(envelope(), new EmailProviderSettings(row(kind), config)))
.hasNoCause().hasMessage("Credential environment variable is not set");
config.withSecretResolver(name ->
{
throw new IllegalStateException(ECHO);
});
assertThatThrownBy(() -> transport.send(envelope(), new EmailProviderSettings(row(kind), config)))
.hasNoCause().hasMessage("Credential environment variable is not set");
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind).withValue("secretEnvName", null))))
.hasNoCause().hasMessage("Provider needs a secret environment name");
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind).withValue("secretEnvName", "UNAPPROVED"))))
.hasNoCause().hasMessage("Credential environment name is not allowed");
for(EmailEnvelope invalid : List.of(envelope().withSubject("Injected\r\nBcc: " + ECHO),
envelope().withTo(List.of("bad\r\naddress")), envelope().withHeaders(Map.of("X-Test", "bad\n" + ECHO))))
{
assertThatThrownBy(() -> transport.send(invalid, settings(row(kind))))
.hasNoCause().hasMessage("Invalid email message");
}
AtomicInteger resolutions = new AtomicInteger();
AtomicInteger sends = new AtomicInteger();
var rotating = new EmailProviderSettings(row(kind), new EmailQBitConfig().withSecretResolver(name -> "token-" + resolutions.incrementAndGet()));
var real = transport(kind, request ->
{
String token = "token-" + sends.incrementAndGet();
String auth = "POSTMARK".equals(kind) ? request.headers().firstValue("X-Postmark-Server-Token").orElseThrow()
: request.headers().firstValue("Authorization").orElseThrow();
String expected = "MAILGUN".equals(kind) ? "Basic " + Base64.getEncoder().encodeToString(("api:" + token).getBytes(StandardCharsets.UTF_8))
: "SENDGRID".equals(kind) ? "Bearer " + token : token;
assertThat(auth).isEqualTo(expected);
return (response("SENDGRID".equals(kind) ? 202 : 200,
"{\"ErrorCode\":0,\"MessageID\":\"id\",\"id\":\"id\"}", Map.of("X-Message-Id", List.of("id"))));
});
assertThat(resolutions.get()).isZero();
real.send(envelope(), rotating);
real.send(envelope(), rotating);
assertThat(resolutions.get()).isEqualTo(2);
}
/*******************************************************************************
** Optional fields are omitted, null tracking is disabled and streams default.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"POSTMARK", "SENDGRID"})
void minimalJsonMessages(String kind) throws Exception
{
AtomicReference<HttpRequest> captured = new AtomicReference<>();
var transport = transport(kind, request ->
{
captured.set(request);
return (response("SENDGRID".equals(kind) ? 202 : 200, "{\"ErrorCode\":0,\"MessageID\":\"id\"}",
Map.of("X-Message-Id", List.of("id"))));
});
for(boolean htmlOnly : List.of(true, false))
{
var minimal = envelope().withFrom("sender@example.com").withCc(List.of()).withBcc(List.of()).withReplyTo(null)
.withTextBody(htmlOnly ? null : "plain").withHtmlBody(htmlOnly ? "<b>html</b>" : null);
transport.send(minimal, settings(row(kind).withValue("messageStream", "")));
JsonNode json = EmailHttpSupport.JSON.readTree(body(captured.get()));
if("SENDGRID".equals(kind))
{
assertThat(json.at("/content").size()).isEqualTo(1);
assertThat(json.at("/personalizations/0").has("cc")).isFalse();
assertThat(json.at("/personalizations/0").has("bcc")).isFalse();
assertThat(json.has("reply_to")).isFalse();
assertThat(json.at("/from").has("name")).isFalse();
}
else
{
assertThat(json.has("ReplyTo")).isFalse();
assertThat(json.has(htmlOnly ? "TextBody" : "HtmlBody")).isFalse();
assertThat(json.path("MessageStream").asText()).isEqualTo("outbound");
}
}
}
/*******************************************************************************
** Authentication and regional requirements are validated without resolving values.
*******************************************************************************/
@Test
void providerValidationAndGmailPreset() throws Exception
{
var config = new EmailQBitConfig().withSecretResolver(name ->
{
throw new AssertionError("Validation must not resolve a secret");
});
for(String kind : List.of("POSTMARK", "SENDGRID", "MAILGUN"))
{
config.withAllowedEnvNames(Set.of());
QRecord provider = row(kind);
assertThat(EmailTableCustomizers.validateProvider(provider, config)).isEmpty();
assertThat(EmailTableCustomizers.validateProvider(provider.withValue("secretEnvName", null), config))
.contains("Provider needs a secret environment name");
provider.withValue("secretEnvName", "CUSTOM_TOKEN");
assertThat(EmailTableCustomizers.validateProvider(provider, config)).isNotEmpty();
assertThat(EmailTableCustomizers.validateProvider(provider, config.withAllowedEnvNames(Set.of("CUSTOM_TOKEN")))).isEmpty();
}
for(String domain : List.of("", "https://example.com", "../example.com", "example.com/path", "example.com?x=y", "-bad.com", "bad-.com"))
{
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("sendingDomain", domain), config)).isNotEmpty();
assertThatThrownBy(() -> new MailgunEmailTransport(request ->
{
throw new AssertionError("Invalid domain must not send");
}).send(envelope(), settings(row("MAILGUN").withValue("sendingDomain", domain))))
.isInstanceOfSatisfying(EmailTransportException.class, error -> assertThat(error.getProviderLevel()).isTrue());
}
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", "EU"), config)).isEmpty();
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", null), config)).contains("Mailgun region must be US or EU");
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", "APAC"), config)).contains("Mailgun region must be US or EU");
Map<String, String> webhookNames = Map.of("POSTMARK", "POSTMARK_WEBHOOK_PASSWORD", "SENDGRID", "SENDGRID_WEBHOOK_PUBLIC_KEY", "MAILGUN", "MAILGUN_WEBHOOK_SIGNING_KEY");
webhookNames.forEach((kind, env) -> assertThat(EmailTableCustomizers.validateProvider(row(kind).withValue("webhookSecretEnvName", env), config)).isEmpty());
assertThat(config.effectiveAllowedEnvNames("SES")).doesNotContain("SES_SNS_TOPIC_ARN");
QRecord gmail = row("GMAIL_SMTP").withValue("usernameEnvName", "GMAIL_USERNAME");
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).isEmpty();
var settings = new EmailProviderSettings(gmail.withValue("host", "ignored.example.com").withValue("port", 25), config);
assertThat(settings.host()).isEqualTo("smtp.gmail.com");
assertThat(settings.port()).isEqualTo(587);
assertThat(settings.security()).isEqualTo("STARTTLS");
assertThat(new EmailTransportRegistry().get("GMAIL_SMTP")).isInstanceOf(SmtpEmailTransport.class);
assertThat(EmailTableCustomizers.validateProvider(gmail.withValue("security", "STARTTLS"), config)).isEmpty();
for(String security : List.of("NONE", "TLS", "invalid"))
{
assertThat(EmailTableCustomizers.validateProvider(gmail.withValue("security", security), config.withAllowPlaintextSmtpAuth(true)))
.contains("Gmail SMTP requires STARTTLS");
assertThatThrownBy(() -> new SmtpEmailTransport().send(envelope(), settings))
.isInstanceOfSatisfying(EmailTransportException.class, error -> assertThat(error.getProviderLevel()).isTrue());
}
gmail.withValue("security", "STARTTLS").withValue("usernameEnvName", null);
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).contains("Gmail SMTP needs an account address environment name");
assertThatThrownBy(() -> new SmtpEmailTransport().send(envelope(), settings)).hasMessageContaining("both credential names");
gmail.withValue("usernameEnvName", "GMAIL_USERNAME").withValue("secretEnvName", null);
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).contains("Provider needs a secret environment name");
assertThat(EmailTableCustomizers.validateProvider(row("GMAIL_API"), config))
.contains("Gmail API sending is specified but not built; use GMAIL_SMTP");
assertThatThrownBy(() -> new EmailTransportRegistry().get("GMAIL_API"))
.hasMessage("Gmail API sending is specified but not built; use GMAIL_SMTP");
assertThatThrownBy(() -> new EmailTransportRegistry().get("future")).hasMessage("Unknown email provider kind");
assertThat(new EmailTransportRegistry().get("POSTMARK")).isInstanceOf(PostmarkEmailTransport.class);
assertThat(new EmailTransportRegistry().get("SENDGRID")).isInstanceOf(SendGridEmailTransport.class);
assertThat(new EmailTransportRegistry().get("MAILGUN")).isInstanceOf(MailgunEmailTransport.class);
}
/*******************************************************************************
** A real socket exchange from enqueue through dispatcher, including stored errors.
*******************************************************************************/
@ParameterizedTest
@CsvSource({
"POSTMARK,200,SENT", "POSTMARK,401,RETRY_WAIT", "POSTMARK,400,DEAD_LETTER", "POSTMARK,429,RETRY_WAIT",
"SENDGRID,202,SENT", "SENDGRID,403,RETRY_WAIT", "SENDGRID,413,DEAD_LETTER", "SENDGRID,503,RETRY_WAIT",
"MAILGUN,200,SENT", "MAILGUN,401,RETRY_WAIT", "MAILGUN,400,DEAD_LETTER", "MAILGUN,429,RETRY_WAIT"
})
void dispatchThroughLocalHttp(String kind, int status, String expectedStatus) throws Exception
{
Instant now = Instant.parse("2026-10-13T12:00:00Z");
AtomicInteger resolutions = new AtomicInteger();
AtomicReference<String> received = new AtomicReference<>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange ->
{
received.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
exchange.getResponseHeaders().set("X-Message-Id", "http-id");
exchange.getResponseHeaders().set("Retry-After", "123");
byte[] reply = ("{\"ErrorCode\":" + (status == 200 ? 0 : 300)
+ ",\"MessageID\":\"http-id\",\"id\":\"<http-id>\",\"Message\":\"" + ECHO + "\"}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, reply.length);
exchange.getResponseBody().write(reply);
exchange.close();
});
server.start();
try(HttpClient client = EmailHttp.newClient())
{
EmailRuntime.setClock(Clock.fixed(now, ZoneOffset.UTC));
H2Support.setup(config -> config.withSecretResolver(name ->
{
resolutions.incrementAndGet();
return (SECRET);
}));
H2Support.insert(EmailTables.PROVIDER, row(kind).withValue("name", "http").withValue("label", "HTTP")
.withValue("enabled", true).withValue("isDefault", true));
var queued = EmailService.send(new EmailRequest().withTo("to@example.com").withCc("cc@example.com").withBcc("bcc@example.com")
.withSubject("HTTP integration").withTextBody("body"));
assertThat(queued.status()).isEqualTo(EmailSendResult.Status.QUEUED);
assertThat(resolutions.get()).isZero();
assertThat(received.get()).isNull();
EmailHttp local = request ->
{
// Only the test seam rewrites the fixed production URL to a local server.
HttpRequest.Builder localRequest = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + request.uri().getPath()))
.timeout(request.timeout().orElseThrow()).POST(request.bodyPublisher().orElseThrow());
request.headers().map().forEach((name, values) -> values.forEach(value -> localRequest.header(name, value)));
return (client.send(localRequest.build(), HttpResponse.BodyHandlers.ofString()));
};
var dispatcher = new EmailDispatcher(new EmailTransportRegistry().withTransport(kind, transport(kind, local)));
dispatcher.runOnce();
assertThat(resolutions.get()).isEqualTo(1);
assertThat(received.get()).contains("to@example.com", "bcc@example.com").doesNotContain(SECRET);
QRecord message = EmailStore.get(EmailTables.MESSAGE, queued.messageId(), null);
QRecord attempt = H2Support.all(EmailTables.ATTEMPT).getFirst();
QRecord provider = H2Support.all(EmailTables.PROVIDER).getFirst();
assertThat(message.getValueString("status")).isEqualTo(expectedStatus);
if("SENT".equals(expectedStatus))
{
assertThat(message.getValueString("providerMessageId")).isEqualTo("http-id");
assertThat(attempt.getValueString("outcome")).isEqualTo("SUCCEEDED");
assertThat(message.getValueString("lastError")).isNull();
}
else
{
assertThat(message.getValueString("lastError")).doesNotContain(SECRET, "private-recipient", ECHO);
assertThat(attempt.getValueString("error")).isEqualTo(message.getValueString("lastError"));
assertThat(provider.getValueString("lastFailureMessage")).isEqualTo(message.getValueString("lastError"));
if(status == 401 || status == 403)
{
assertThat(provider.getValueString("healthStatus")).isEqualTo("UNHEALTHY");
assertThat(attempt.getValueString("outcome")).isEqualTo("PROVIDER_FAILURE");
}
else
{
assertThat(attempt.getValueString("outcome")).isEqualTo("DEAD_LETTER".equals(expectedStatus) ? "PERMANENT_FAILURE" : "TRANSIENT_FAILURE");
}
if("RETRY_WAIT".equals(expectedStatus))
{
assertThat(message.getValueInstant("nextAttemptDate")).isEqualTo(now.plusSeconds(123));
}
}
}
finally
{
server.stop(0);
QContext.clear();
EmailRuntime.setClock(null);
}
}
}
+48 -1
View File
@@ -1,4 +1,4 @@
-- Phase (a), part 1. Apply before producing the qbit. Host security columns must be added by the host.
-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host.
CREATE TABLE email_provider (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -58,6 +58,8 @@ CREATE TABLE email_message (
html_body TEXT,
text_body TEXT,
body_sha256 VARCHAR(500),
phi_approved BOOLEAN,
unsubscribe_url TEXT,
idempotency_key VARCHAR(500),
provider_id INTEGER,
provider_message_id VARCHAR(500),
@@ -68,6 +70,8 @@ CREATE TABLE email_message (
finished_date TIMESTAMP,
sent_date TIMESTAMP,
delivered_date TIMESTAMP,
bounced_date TIMESTAMP,
complained_date TIMESTAMP,
dead_letter_date TIMESTAMP,
linked_table_name VARCHAR(500),
linked_record_id VARCHAR(500),
@@ -105,6 +109,8 @@ CREATE TABLE email_template (
kind VARCHAR(500),
brand_id INTEGER,
active_version_id INTEGER,
approved_version_id INTEGER,
version_lock INTEGER,
phi_approved BOOLEAN,
phi_approved_by VARCHAR(500),
phi_approved_date TIMESTAMP,
@@ -148,6 +154,8 @@ CREATE TABLE email_brand (
background_color VARCHAR(500),
dark_background_color VARCHAR(500),
dark_text_color VARCHAR(500),
dark_primary_color VARCHAR(500),
dark_button_text_color VARCHAR(500),
button_text_color VARCHAR(500),
footer_markdown TEXT,
postal_address TEXT,
@@ -176,3 +184,42 @@ CREATE TABLE email_suppression (
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
CREATE TABLE email_webhook_inbox (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
provider_id INTEGER NOT NULL,
kind VARCHAR(500) NOT NULL,
external_event_id VARCHAR(500) NOT NULL,
nonce VARCHAR(500),
status VARCHAR(500) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_date TIMESTAMP,
last_error VARCHAR(500),
payload_json TEXT,
payload_sha256 VARCHAR(500),
received_date TIMESTAMP,
processed_date TIMESTAMP,
UNIQUE (provider_id, external_event_id),
UNIQUE (provider_id, nonce)
);
CREATE TABLE email_event (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
message_id INTEGER NOT NULL,
provider_id INTEGER,
event_type VARCHAR(500) NOT NULL,
bounce_type VARCHAR(500),
recipient VARCHAR(500),
occurred_date TIMESTAMP,
detail VARCHAR(500),
url TEXT,
event_key VARCHAR(500) NOT NULL UNIQUE
);
CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date);
CREATE INDEX email_event_message ON email_event (message_id);
CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date);
CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);
+13
View File
@@ -0,0 +1,13 @@
# Test-only SNS certificate and private key
These are public test fixtures, never production credentials. Generated at authoring
time with OpenSSL; tests do not spawn OpenSSL or write certificate files.
```
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 36500 \
-subj "/CN=TEST ONLY - SNS webhook verification fixture" \
-keyout test-only-private-key.pem -out test-only-certificate.pem
```
The fake EmailHttp serves this certificate for an AWS-shaped test URL. SNS tests
sign with this key in memory. Never trust this certificate outside the tests.
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDUTCCAjmgAwIBAgIUMxFivUctd7E0lQz94zgDEkGMm8EwDQYJKoZIhvcNAQEL
BQAwNzE1MDMGA1UEAwwsVEVTVCBPTkxZIC0gU05TIHdlYmhvb2sgdmVyaWZpY2F0
aW9uIGZpeHR1cmUwIBcNMjYxMDE0MDI0OTM4WhgPMjEyNjA5MjAwMjQ5MzhaMDcx
NTAzBgNVBAMMLFRFU1QgT05MWSAtIFNOUyB3ZWJob29rIHZlcmlmaWNhdGlvbiBm
aXh0dXJlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwDnXnAbhkNrq
Hi05Rl33RojxWRN8N81yEISzyrp8zTBXg15Icp8dkLIEkTYrcZv5GDZ/ROE95nHG
Ue9ODyK3ga8X4yyHZQEtc3F0SyCwwCg/sRIx2F217cnrr2jbF57mZBiUqcr0+naa
ZyqpBsV4iBnZSIQchi0RWTkPI7kj0oJi9uWBOf7sVQBlKFyboTRloFLxnB4XdBQ5
PYNiNT9pv8drCe3B9OcAu9odhRuI/fvws1C+zlDxqxlCvYGonUvGD9ob9x5NkuUg
hABxyUYcZWgMzSBTcrL7vIZyTLsgEqRB2IZQDeqZhpcEc2ewrWvnTPzPVc9rzew1
8hltQd4E2wIDAQABo1MwUTAdBgNVHQ4EFgQUIfc+4qImdzoXevD8eAK8iFynrTQw
HwYDVR0jBBgwFoAUIfc+4qImdzoXevD8eAK8iFynrTQwDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAQEAFaUW8M16oVhV/lERu+JXeasCtvLrudrufomH
HybCP+YHbgee4RnIBDjKOzDZRpbjJmi/a7rJidtpdnD8Dn1lexB7wEDmgDdyB4oT
4R62dKuOQgnL/RuDUUp3YYMZliSppSNQRgOYl7Islq3zmpYBCBAV9s4fiUHW8uBy
ZYRQbF49xxPINpSreMJtrf+Pq9E1As4cCc3ViSS1ITsH4R515xJvQg64HQtunl8W
+rSzTfsAADCqqFapmdLQiHSJWIEXxbNv1B0vBSIJCQkNsjTceblgqYTAdK1Kufsw
s5ZWUbQHAIXODa3VRvaislvH+ygHAtIBi1kyV33c2AiuPPVuqg==
-----END CERTIFICATE-----
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDAOdecBuGQ2uoe
LTlGXfdGiPFZE3w3zXIQhLPKunzNMFeDXkhynx2QsgSRNitxm/kYNn9E4T3mccZR
704PIreBrxfjLIdlAS1zcXRLILDAKD+xEjHYXbXtyeuvaNsXnuZkGJSpyvT6dppn
KqkGxXiIGdlIhByGLRFZOQ8juSPSgmL25YE5/uxVAGUoXJuhNGWgUvGcHhd0FDk9
g2I1P2m/x2sJ7cH05wC72h2FG4j9+/CzUL7OUPGrGUK9gaidS8YP2hv3Hk2S5SCE
AHHJRhxlaAzNIFNysvu8hnJMuyASpEHYhlAN6pmGlwRzZ7Cta+dM/M9Vz2vN7DXy
GW1B3gTbAgMBAAECggEATrQgHEkQ9+855iscBTyZ7u5I6vC4blyWtD6xInMigByn
6CilPxD42PKaPh5QwU5cOdauWzRh1MWGrqttaXMMcXOQMzpbITbPNjIgrFXxKGwS
T9ykPfN4dqiy7/ndopkzuY366F4p2ZbP9yeXVydpB0iWF0PILgOr070x/q1iBmEX
zY/rTgSO3xhS6fJOtZdCFvSY7Geg8pPYJ1pAYBaz2nPL1WYXN4M0qpllBLKBwwnf
i6PRxLAyATLpOJgmI9sGmbfvu7yKRIAvVItR+2r5v03Fh7brKEOq9YmmzY7Hx62L
VWsr6CMzUrsUm7R54epPeBBvhZ0AIWAf9gIDemvmUQKBgQDz4lms/uFjn6h9aoNX
Xk/AdZGKp44d3vXF7X3MrKB10zPPg13n/Wwp0DrGP08aZMMbWevROIHfzToTFHkW
sYiwmazF9y0+6tf0BmV8EuR7vJtIhKwTJAgdFbbTylXUdNc+5rh+O6IoImlpCD75
rQfPe3fezlxzYmC43Q68WfRsYwKBgQDJxoRl8/a0jNx4ZFZzYfxk/uG5/HdY+dSS
SecJvj9M6CTDbA8k8wBWgdsgEgvB5qCjTe7Bci9dagDcL4qUsAl4H05I09VVIbUd
3h7JCWwVFr/Dvn6L7tqeJXlq5yDPfYDPvbe2EK+x/Zq/QTVtPYFqtlH83U78cU4N
3g9mP1aDKQKBgCQU+O9/RhlXJbZoa8YhTetLeZzp7Ty+H96IpHQDljnYZXHuHZaV
B8un4woxk6J0BemmBB2QHqbd1hm8ja2t1mtKUSV4zFjipdCUoa8ZnHQyE0dHWa37
RrgG3mW3KbulDbNgryr955/Id4/yiF6oGt+3hKSSI1tu0qCHFAcJx//BAoGAJE4j
5HRRH4t43/Ct3XT0BPgXhW93yfqp1OiXOZmRqjTa6I646t0OYoJa+xpEI09r9/Y9
z3DMNPKt1PB47uf9zC9V1qTG7dpyzDz7LREU8XZ2arIL8wG3NF1py2Jq9OqQEtEA
DTJTaqNHBMKm6lD2yEyl0+m+HI4GjVg3TR54YoECgYEA3kOMd1NuVDNHQU6qRD54
oGvym00jaCYCH1mQvS7VWOkXfmT6ZnKw+qRLcoUGTtP76pcWNYQQMyUDfbdtQp1u
gdrlS/zASsQhuDaKrCiuwOIlTvlx7InRi2MqpN+ztus8hZzyBHg9EHUWGykZ7rRh
XtLanuf3Tn3MvyiHNaVPyQs=
-----END PRIVATE KEY-----
@@ -0,0 +1,23 @@
{
"signature": {
"timestamp": "1791892800",
"token": "test-mailgun-nonce",
"signature": "signed-in-test"
},
"event-data": {
"id": "mailgun-event-1",
"event": "failed",
"severity": "permanent",
"timestamp": 1791892800,
"recipient": "recipient@example.com",
"message": {
"headers": {
"message-id": "<provider-message-1>"
}
},
"delivery-status": {
"message": "550 recipient@example.com unavailable"
},
"user-variables": {}
}
}
@@ -0,0 +1,9 @@
{
"RecordType": "Bounce",
"ID": 10001,
"MessageID": "provider-message-1",
"Type": "HardBounce",
"Email": "recipient@example.com",
"BouncedAt": "2026-10-13T12:01:00Z",
"Description": "550 mailbox recipient@example.com is unavailable"
}
@@ -0,0 +1,7 @@
{
"RecordType": "Delivery",
"MessageID": "provider-message-1",
"Recipient": "recipient@example.com",
"DeliveredAt": "2026-10-13T12:00:00Z",
"Details": "250 accepted"
}
@@ -0,0 +1,19 @@
[
{
"email": "recipient@example.com",
"timestamp": 1791892800,
"event": "delivered",
"sg_event_id": "sg-event-1",
"sg_message_id": "provider-message-1.filter001",
"response": "250 accepted"
},
{
"email": "recipient@example.com",
"timestamp": 1791892860,
"event": "bounce",
"type": "bounce",
"sg_event_id": "sg-event-2",
"sg_message_id": "provider-message-1.filter001",
"reason": "550 recipient@example.com does not exist"
}
]
@@ -0,0 +1,10 @@
{
"Type": "Notification",
"MessageId": "sns-envelope-1",
"TopicArn": "arn:aws:sns:us-east-1:123456789012:email-events",
"Timestamp": "2026-10-13T12:00:00Z",
"SignatureVersion": "2",
"SigningCertURL": "https://sns.us-east-1.amazonaws.com/test-only.pem",
"Signature": "signed-in-test",
"Message": "{\"eventType\": \"Bounce\", \"mail\": {\"messageId\": \"provider-message-1\", \"destination\": [\"recipient@example.com\", \"second@example.com\"], \"headers\": [{\"name\": \"X-QQQ-Email-Id\", \"value\": \"1\"}]}, \"bounce\": {\"bounceType\": \"Permanent\", \"bounceSubType\": \"General\", \"timestamp\": \"2026-10-13T12:01:00Z\", \"bouncedRecipients\": [{\"emailAddress\": \"recipient@example.com\", \"diagnosticCode\": \"smtp; 550 recipient@example.com unavailable\"}, {\"emailAddress\": \"second@example.com\", \"diagnosticCode\": \"smtp; 550 second@example.com unavailable\"}]}}"
}