Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 6dd1a00fbb feat(help): ship the qbit-email help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:27:27 -05:00
james.maes 388f3eeade Merge pull request #17 from QRun-IO/feature/foreign-keys
Foreign keys between the email tables, with an orphan-safe upgrade
2026-10-14 18:00:28 -05:00
james.maesandClaude Opus 5.5 534d61c19a Merge origin/develop into feature/foreign-keys
Keeps develop's liquibase-core test dependency next to the Postgres
Testcontainers dependencies, and both changelog entries. H2 tests load
src/test/resources/email-h2.sql (FKs in H2 syntax), never the Postgres DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:14 -05:00
james.maes 28d0178a66 Merge pull request #19 from QRun-IO/feature/tables-populated-defaults
Every Email table used, seeded or explained; setup card and help
2026-10-14 17:31:48 -05:00
james.maesandClaude Opus 5.5 21d754d68c docs(help): reword the stand-in note in the help bundle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:23:56 -05:00
james.maesandClaude Opus 5.5 fb31d1e542 feat: starter brand, setup card and table help so no config table starts empty
Adds email-defaults.changelog.sql (non-default neutral brand, only when no brand
exists; tested through real Liquibase), help for the app and every table, a
Setup card on the home, guardrail tests and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:13:37 -05:00
james.maesandClaude Opus 5.5 6e9351e8e3 fix(templates): use a template's owner qbit as the message sender
email_template.ownerQbit was stored but never read. Messages sent from a
template now name its owner qbit as senderQbit unless the request names one,
the built-in secure-message template sets it, and the field is read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:13:36 -05:00
james.maes ccb256769c Merge pull request #18 from QRun-IO/feature/email-job-effect-target
feat: EmailJobEffectTarget, so durable jobs can send email through the outbox
2026-10-14 16:43:57 -05:00
james.maesandClaude Opus 5.5 2a2d69c5f6 feat: EmailJobEffectTarget, so durable jobs can send email through the outbox
Built on QQQ 4.1.0-RC.7, which has the durable jobs side-effects outbox.
- EmailJobEffectTarget implements core's EffectTarget.
  - Delivery hands the email to qbit-email's own outbox with the effect's
    idempotency key, so it lands exactly once per key, with the fixed
    Message-ID. The result is SENT with the message id, or FAILED when
    refused.
  - It runs as the job owner (JobSessions.forJob). If the owner can no longer
    send, the result is FAILED, never UNKNOWN.
  - Reconcile looks the message up by key.
  - Email effects through it are IDEMPOTENT_EXTERNAL.
- EmailQBitProducer registers it as target "email" when durable jobs are
  enabled (EmailJobEffectTarget.register).
- docs/DURABLE-JOBS.md and the README describe declaring and emitting email
  effects.

verify: 301 tests (14 new), checkstyle clean, coverage 95.4% / 100%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:33:37 -05:00
james.maesandClaude Opus 5.5 877b6eb6fb feat(ddl): foreign keys between the email tables, with an orphan-safe upgrade
Declare a named foreign key for every join and table possible-value field
between the ten email tables, plus suppression.source_message_id. Owned
rows cascade, providers/brands/templates with history are RESTRICT, and
optional pointers (template versions, sender address, suppression sources)
are SET NULL so message retention keeps working. The provider delete check
also covers attempts, events and inbox rows. The upgrade script quarantines
orphans before adding the keys; a Testcontainers guardrail test fails when
a new relation has no key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:30:06 -05:00
james.maes 303848a86b Merge pull request #14 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:46:00 -05:00
james.maes 3168b1979b Merge pull request #15 from QRun-IO/feature/readiness-checks
feat(api): structured readiness checks
2026-10-14 10:16:03 -05:00
james.maesandClaude Opus 5.5 28b964ab17 feat(api): structured readiness checks
EmailApiReadiness carries failedChecks (EmailApiReadinessCheck) next to
the plain reasons, so callers such as qbit-marketing can tell a missing
unsubscribe key from a missing provider without parsing text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:12:31 -05:00
james.maesandClaude Opus 5.5 30352cf7c5 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:58 -05:00
james.maes d2bddc968a Merge pull request #13 from QRun-IO/feature/public-api
feat: public api package for other qbits
2026-10-14 09:44:03 -05:00
james.maesandClaude Opus 5.5 acff558a9a feat(api): fold EmailEffects into EmailApi
EmailApi.sendOnce(request, effectKey) with EmailApiEffectOutcome and
EmailApiEffectResult, lookups by idempotency key and RFC Message-ID, and
rfcMessageId on EmailApiMessage, so other qbits have one public surface.
EmailEffects and its types stay as internal, deprecated delegates; the
durable-jobs guide, README and DESIGN 2.5 point at EmailApi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:41:29 -05:00
james.maesandClaude Opus 5.5 a3ea38eb7e Merge develop (fixed Message-ID, EmailEffects) into feature/public-api
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:35:39 -05:00
james.maes 6eb5854fb4 Merge pull request #12 from QRun-IO/feature/effect-message-id
feat: fixed Message-ID per email and an effect handoff API for durable jobs
2026-10-14 09:30:01 -05:00
james.maesandClaude Opus 5.5 5f6b661d1a feat: public api package for other qbits
com.kingsrook.qbits.email.api is the stable contract for other qbits:
EmailApi (isInstalled, send, ensureTemplate, message lookups by id and
idempotency-key prefix with metadata only, sender addresses, default
provider, readiness per category) with its own request, result,
template, message and readiness types. A reflection test keeps internal
types out of every public signature. The README and DESIGN 2.5 document
the two supported routes: core SendMessageAction with the qbitEmail
provider for plain mail, and the api package for the rest; internal
packages are not API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:02:41 -05:00
47 changed files with 6040 additions and 95 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+19 -1
View File
@@ -5,9 +5,27 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
### Changed
- Built on QQQ 4.1.0-RC.7 (default QQQ profile).
### Added
- Foreign keys between all ten tables in `docs/ddl/email-postgres.sql`, named `fk_<table>_<column>`. CASCADE: a provider's sender addresses and a message's attempts and events. RESTRICT: providers with messages, attempts, events or webhook inbox rows; a brand used by a template; a template with versions. SET NULL: a message's sender address, template, version and brand; a template's active and approved version (the template/version cycle); a suppression's source message and event, so suppressions survive message retention. Ten new indexes cover the unindexed key columns. `docs/ddl/email-foreign-keys-upgrade.sql` adds the keys to an existing database. It first records orphan rows in `qqq_fk_quarantine`: nullable references are set to NULL, and rows whose required parent is missing are deleted and kept as JSON. The script is idempotent. The provider delete check now also refuses providers with attempts, events or inbox rows. `ForeignKeyGuardrailTest` (PostgreSQL 17 via Testcontainers) fails when a join or table possible-value field between email tables has no foreign key.
- Starter defaults changelog `db/liquibase/qbit-email/email-defaults.changelog.sql` (insert-only, guarded): one non-default, high-contrast `neutral` brand, only when no brand exists. Tested through real Liquibase on H2, including that it never changes rendering.
- Host-neutral in-app help bundle for the Email app, tables, fields, actions and widgets, registered through QQQ's help bundle API. Replaces the temporary inline loader, preserves record-creation explanations and supports HOST overrides. The coverage gate checks owned metadata, summaries and live references.
- A Setup card first on the Email home (email admins), using the sender's own provider and brand resolution and `EmailApi.readiness`. Opening the home also creates the built-in secure-message template.
- `EmailTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
### Fixed
- `email_template.ownerQbit` was stored but never read, and the built-in template did not set it. The built-in now names `qbit-email`, the field is read-only with help, and messages sent from a template name its owner qbit as `senderQbit` unless the request names one.
### Added
- `EmailJobEffectTarget` automatically registers the durable-jobs `email` target, with idempotent outbox handoff, refusal reasons, reconciliation and job-owner session enforcement; H2 integration coverage and payload/registration documentation.
- `EmailApiReadiness.failedChecks()` and `failed(check)` with `EmailApiReadinessCheck` (NOT_INSTALLED, PROVIDER_MISSING, PROVIDER_INVALID, PHI_PROVIDER, PUBLIC_BASE_URL, UNSUBSCRIBE_KEY, BRAND_POSTAL_ADDRESS, BODY_KEY), so callers react to a specific prerequisite without parsing reason text.
- `EmailApi.sendOnce` as the public durable-effect handoff, with API-only outcome/result types, committed enqueue/replay semantics and suppression/transaction refusal parity. Exact idempotency-key and RFC Message-ID lookups return `EmailApiMessage`, which now includes `rfcMessageId` across all lookup routes. Public requests support ordinary caller transactions; `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internals. H2 parity, reconciliation, storage-failure and API-boundary coverage; no new dependencies.
- Stable `com.kingsrook.qbits.email.api` contract for cross-qbit sends, create-once templates, metadata-only message lookups, enabled sender addresses, default provider selection and secret-safe readiness (including marketing and PHI prerequisites). JDK/QQQ-only signatures, complete request/status mapping tests and a reflection package-boundary guard; no new dependencies.
- Documented both supported cross-qbit routes: core-only `SendMessageAction` via `qbitEmail`, and the public API for richer sends and lookups.
- Persisted unique RFC Message-IDs, stable across delivery attempts, with SMTP/Gmail SMTP, SES raw MIME and Postmark/SendGrid/Mailgun request propagation (SES replaces the header downstream).
- Core-independent `EmailEffects` handoff, reference lookups by idempotency key or RFC Message-ID, refusal mapping and an RC.7 binding design sketch; offline retry and provider-contract tests.
- Core-independent `EmailEffects` handoff, reference lookups by idempotency key or RFC Message-ID and refusal mapping; offline retry and provider-contract tests.
- Checkstyle now checks test sources as well as main sources; existing test imports and formatting corrected.
- Provider sender addresses: child table `email_provider_sender_address` (validated lowercase address, display name, one default, enabled, note; unique per provider; domain bound to the provider's sending domain; admin-only), shown on the provider record. `EmailRequest.withFromAddressId`, `from_address_id` on messages, refusal of a from address that is not an enabled row of the resolved provider, and the provider `from_address` / `from_name` kept as the fallback.
- Contingent possible-value filters on the new from-address pickers (send processes by `providerId`, Send Test Email by the acted-on provider, message `fromAddressId` by `providerId`) and on the attempt and event message pickers (by `providerId`) and the suppression source event (by `sourceMessageId`); the send processes also take `providerId`.
+148 -33
View File
@@ -13,7 +13,7 @@ qbit-object-storage is wired in.
## Install
Requires Java 21, QQQ **4.1.0-RC.5**, and a transactional RDBMS with the supplied unique
Requires Java 21, QQQ **4.1.0-RC.7**, 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.
The physical table names equal their metadata names; `backendName` selects the host's
QQQ database backend, as it does in qbit-webhooks.
@@ -39,6 +39,30 @@ The scheduler must already exist. Without a scheduler, callers can enqueue and r
Configuration is looked up from the current `QInstance` on every call, never cached
in a static variable.
## Starter defaults and setup
Include order: the DDL first, then the starter defaults from the host's master changelog (the qbit itself never runs DDL):
```yaml
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
```
They are insert-only, hold no secrets, skip a host without the tables, and never change how mail looks on deploy. They add one brand, `neutral` (white background, near-black text, blue buttons, light and dark schemes, every pair at least 4.5:1 contrast), and only when the host has no brand at all. It is not the default brand: copy it for your own, or tick Default Brand on it.
Everything else starts empty on purpose, and each table's help says why: a **provider** is your own connection (a server and the NAMES of its secret variables), so nothing can be sent until you add one; messages, attempts, events, the webhook inbox and suppressions are runtime data; the secure-message template is created by the qbit on first use and when the Email home opens, and other qbits register theirs with `EmailApi.ensureTemplate`.
Every table has help that says what it is for and **how its rows get created**, available through the host's help bundle sync. Email admins also get a **Setup** card first on the home: provider, sender addresses, brands, templates, marketing readiness, delivery tracking, suppression list and scheduler, each with its state and the next step. It uses the same provider and brand resolution as the sender and `EmailApi.readiness`.
### In-app help
The producer registers [help/qbit-email.yaml](src/main/resources/help/qbit-email.yaml) as a `QBIT` bundle using QQQ's help bundle API. Registration is metadata-only and works without a `helpContent` table; hosts with help enabled load and sync registered bundles through QQQ. The app entry follows `EmailQBitConfig.appName`.
To override wording or add deployment-specific instructions, register a separate `layer: HOST` bundle with the same entry keys and roles using `HelpBundles.register(qInstance, "help/your-host.yaml")`. HOST entries take precedence over QBIT entries; UI edits remain protected by the core sync. Keep the bundle version aligned with the POM's artifact version when releasing.
`EmailTablesGuardrailTest.everyTableHasEmptyStateHelp` runs `HelpBundleAssertions` against the produced instance to check owned objects, non-trivial fields, summaries and live references, with no exclusions. `EmailQBitProducerTest` checks registration without a help table and with a custom app name. Both run during `./mvnw verify`.
A template's **Owner Qbit** (the qbit that registered it) is now used: messages sent from the template name that qbit as their sender unless the request names one.
## Tables and permissions
| Table | Navigation | Writes |
@@ -157,7 +181,99 @@ 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
## Sending from another qbit
There are two supported routes. Both queue through the durable outbox; a successful
call acknowledges enqueueing, not delivery.
**1. QQQ core only, for simple subject/body email.** The host installs qbit-email;
the consuming qbit needs no qbit-email dependency or imports. Use
`SendMessageAction` with `messagingProviderName` set to `qbitEmail` (the default
bridge name; hosts can configure another name):
```java
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName("qbitEmail")
.withTo(new Party().withAddress("pat@example.com"))
.withSubject("Ready")
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Your export is ready.")));
```
**2. The published `com.kingsrook.qbits.email.api` package**, for templates,
idempotency, categories, linked records, sender selection and delivery lookups.
Consumers with a qbit-email dependency import only this package:
```java
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
EmailApiResult result = EmailApi.send(new EmailApiRequest()
.withTo("pat@example.com")
.withTemplateName("order_ready")
.withVariable("order_number", "A-1")
.withVariable("customer_name", "Pat")
.withIdempotencyKey("orders:42:ready")
.withLinkedRecord("orders", 42)
.withSender("orders", "ready"));
```
`EmailApi.ensureTemplate(EmailApiTemplate)` creates a template once; see
[Templates from another qbit](#templates-from-another-qbit).
For durable job effects, use `EmailApi.sendOnce(request, effectKey)`. It assigns
the effect key as the request's idempotency key and returns `EmailApiEffectResult`
with `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE` or `REFUSED` and a nullable
`EmailApiMessage`. Success means the outbox has committed; duplicate handoffs return
the same row and RFC Message-ID. Null requests, null/blank/over-500-character keys,
conflicting request keys and caller transactions are refused. Suppressed rows remain
refused on replay and include their audit metadata; refusals without a row have no
message. Database failures throw `QException` for retry or reconciliation. Ordinary
`EmailApi.send` accepts `request.withTransaction(transaction, backendName)` when a
caller-owned or cross-backend deferred enqueue is needed. See [durable job effects](docs/DURABLE-JOBS.md).
`EmailApi.isInstalled(instance)` detects whether the qbit was produced.
`defaultProviderName(instance)` resolves an enabled configured/default provider;
`listSenderAddresses(providerName)` lists enabled sender rows (null selects the
default). `readiness(instance, category)` reports configuration prerequisites,
including MARKETING's public URL, unsubscribe key and default brand postal address.
For a PHI-approved template, use `readiness(instance, category, true)` to also check
the body key and provider PHI/tracking policy. PHI is a template approval state,
not a category. Readiness is a preflight, not proof that a particular send will pass.
`getMessage(id)`, `getMessageByIdempotencyKey(key)`, `getMessageByRfcMessageId(rfcMessageId)`
and `findMessagesByIdempotencyKeyPrefix(prefix, limit)` expose only
id, delivery status, category, idempotency key, RFC Message-ID, provider message id, template name,
linked table/id and create/sent/delivered timestamps. They never return addresses,
subjects, bodies or variable snapshots. Prefixes are literal and nonblank; limits
must be positive; results are ordered by ascending message id. Missing messages
return an empty Optional/list. Exact-key lookups also return empty for null/blank
values; RFC lookup takes the stored bracketed header, not a provider response id.
Ordinary enqueue results have `QUEUED`, `DUPLICATE`,
`SUPPRESSED` or `REFUSED`; delivery status is a separate string on `EmailApiMessage`.
Database calls require the caller's initialized `QContext`; methods accepting an
installed instance require it to be the current context instance. Lookups run as
the email system, so callers must authorize any exposure to their own users.
Sending retains the existing permission, schema, PHI, suppression and recipient
rules. Database failures propagate as core `QException`; invalid template definitions
and lookup arguments throw `IllegalArgumentException`. No secret values are exposed.
The API uses only JDK, QQQ core and its own API types in published signatures.
Changes within a `0.x` minor release line are additive only. Internal packages
(including runtime, configuration, outbox, metadata, support and template classes)
are not API and may change. `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef`
are deprecated internals; `EmailApi` is the sole public qbit-email surface.
The internal service details below describe host and
qbit-email implementation behavior, not additional cross-qbit contracts.
## Internal sending service
```java
EmailSendResult result = EmailService.send(new EmailRequest()
@@ -271,14 +387,14 @@ 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:
administrator to create it. Describe it with `EmailApiTemplate` and call
`EmailApi.ensureTemplate` at startup or before first use:
```java
boolean created = EmailTemplates.ensure(new EmailTemplateDefinition()
boolean created = EmailApi.ensureTemplate(new EmailApiTemplate()
.withName("order_ready")
.withLabel("Order ready")
.withCategory(EmailCategory.TRANSACTIONAL)
.withCategory(EmailApiCategory.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")
@@ -290,7 +406,7 @@ boolean created = EmailTemplates.ensure(new EmailTemplateDefinition()
`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
the JSON. When no template of that name exists, `ensureTemplate` 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;
@@ -449,17 +565,10 @@ 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.
For schema-aware calls from another qbit, use `EmailApi.send` with an
`EmailApiRequest` and declared template variables as described above. Ordinary
core `SendMessageInput` is ad-hoc and cannot infer classifications from arbitrary
pre-rendered text. The existing typed bridge extension is an internal interface.
## Configuration
@@ -503,23 +612,29 @@ PHI canary egress, encryption, preview widgets and home data.
## Durable jobs
`EmailEffects.send(request, effectIdempotencyKey)` hands a message to the committed
email outbox exactly once per key and returns an `EmailEffects.Result` containing
`EmailEffectOutcome` and an `EmailMessageRef`. `QUEUED_OR_DUPLICATE` means the outbox
accepted responsibility; `REFUSED` means a definite refusal, including suppression.
Use `findByIdempotencyKey` or `findByRfcMessageId` to reconcile a handoff. These are
trusted system reads returning only references. Database failures propagate.
Requests must not carry a caller transaction; the effect dispatcher calls after its
own chunk commit. Existing `EmailService` transaction support is unchanged.
Built on QQQ **4.1.0-RC.7**. Enable `DurableJobsMetaData` and define the jobs tables
**before** producing the email qbit: `EmailQBitProducer` automatically registers
`EmailJobEffectTarget` under target name `email`. If jobs are enabled later, call
`EmailJobEffectTarget.register(instance)` afterward. Registration is a no-op when
jobs are disabled.
Each new message stores a unique `rfcMessageId` once. SMTP (including the Gmail preset),
Postmark, SendGrid and Mailgun carry it on every attempt. SES receives it in the raw
request but replaces it downstream; every transport retains `X-QQQ-Email-Id`.
Message-ID enables correlation and receiver deduplication where supported; it does
not promise exactly-once inbox delivery.
Declare `new JobEffectDeclaration("welcomeEmail", "<step>", "email",
JobEffectType.IDEMPOTENT_EXTERNAL)` in the process's `DurableProcessMetaData`, then
call `JobEffects.emit(input, "welcomeEmail", rowKey, payload)` in the backend step.
The payload accepts recipients, template or ad-hoc bodies, variables, routing and
record linkage; see the [complete payload contract and examples](docs/DURABLE-JOBS.md).
See [the handoff contract, provider references, schema upgrade and RC.7 binding sketch](docs/DURABLE-JOBS.md).
The build remains on released QQQ **4.1.0-RC.5**, without durable-jobs dependencies.
The target deduplicates handoffs by the effect key. SENT means the email outbox has
committed, while its dispatcher owns provider delivery and retries. Job effects
send under the job owner's current session and captured security scope; an owner
whose session cannot be established produces FAILED. Other exceptions produce
UNKNOWN and can safely be retried using the same key. Reconcile finds the existing
outbox row, including terminal audit rows.
Each message retains its stored RFC Message-ID across provider attempts. SMTP,
Postmark, SendGrid and Mailgun carry it; SES replaces it downstream. This supports
correlation and receiver deduplication where available, without guaranteeing
exactly-once inbox delivery. See the [schema upgrade and provider notes](docs/DURABLE-JOBS.md).
## License
+13
View File
@@ -0,0 +1,13 @@
# Upgrading
## Unreleased: starter defaults, setup card, table help
1. **Add the defaults changelog** after the changesets that create the `email_*` tables (`docs/ddl/email-postgres.sql`) in the host's master changelog:
```yaml
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
```
It is insert-only and guarded: a host that already has any brand gets nothing, a host without the table skips it. The `neutral` brand it adds is not the default brand, so existing mail renders exactly as before.
2. **Behavior change.** Messages sent from a template whose Owner Qbit is set, with no sender named in the request, now record that qbit as `senderQbit` (it was empty). Nothing else about sending changes.
3. The help bundle and the Setup card need no configuration. Existing built-in templates created before this release keep an empty Owner Qbit.
+51 -2
View File
@@ -44,7 +44,7 @@ unsubscribe and PHI rules.
## 2. Sending API
### 2.1 `EmailService` (Java, for every consumer)
### 2.1 `EmailService` (internal Java enqueue service)
```java
EmailSendResult result = EmailService.send(new EmailRequest()
@@ -96,9 +96,58 @@ template may set `edit_permission_name` to restrict who edits it (e.g. marketing
- Attachments are stored through qbit-object-storage when it is installed, else refused with a
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`
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailApi`
in phase (d) for templates, categories, idempotency and linked records.
### 2.5 Public API
`com.kingsrook.qbits.email.api.EmailApi` is the sole public qbit-email surface for consuming qbits.
Changes within a `0.x` minor release line are additive only. Public/protected
signatures contain only JDK, QQQ core or API-package types; internal packages are
not API and may change. A reflection test discovers every compiled API class and
checks signatures, including generic arguments, bounds and nested types.
- `EmailApi.send(EmailApiRequest)` maps to the existing outbox service, preserving
permissions, schema/PHI validation, suppression and one-recipient rules. Requests
cover recipients/sender selection, body alternatives, template variables and record
provenance, category/provider/brand/locale, idempotency, linked records, producer
identity, scheduling and caller transactions (`withTransaction(transaction, backendName)`).
Results expose message id, enqueue status and reasons.
- `sendOnce(EmailApiRequest, effectKey)` provides durable handoff through the existing
internal effects implementation. The key becomes the request's idempotency key;
null requests, missing/blank/over-500-character keys, mismatches and caller transactions
are refused. `EmailApiEffectResult` carries `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE`
or `REFUSED` and nullable `EmailApiMessage`. Success means committed outbox storage,
and replays retain the row and RFC Message-ID. Suppressed audit rows remain refused
on replay and return metadata; refusals without a row return null. Operational
failures throw `QException`. The deprecated `EmailEffects`, `EmailEffectOutcome` and
`EmailMessageRef` remain internal; consumers use this API. See [durable jobs](DURABLE-JOBS.md).
- `ensureTemplate(EmailApiTemplate)` delegates create-once template registration;
`EmailApiVariable` carries the variable contract. Existing content is never
overwritten, and the API does not confer PHI approval.
- `getMessage`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and
bounded, literal `findMessagesByIdempotencyKeyPrefix` perform
system reads of an explicit metadata projection: id/status/category, idempotency
key, RFC Message-ID and provider id, template name, linked record and create/sent/delivered dates.
Exact-key lookups return empty for null/blank/missing values. RFC lookup requires
the exact bracketed header stored at enqueue time, distinct from provider response ids.
No recipients, subjects, bodies or snapshots are fetched or returned by lookups.
Callers own authorization before presenting metadata to users.
- `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace
consumer access to runtime/configuration/tables. Readiness checks an enabled
default provider; MARKETING also needs a valid publicBaseUrl, available unsubscribe
key and default brand postal address. The PHI overload checks body key availability
and provider PHI/tracking policy. Reasons never contain resolved secret values.
PHI is a template state, so it is an explicit option rather than a fifth category.
- Database operations use the current `QContext`; an installed instance argument
must match that context. Database errors remain core `QException`. Readiness does
not validate an individual request or guarantee delivery.
For simple subject/body sends, consumers need no qbit-email dependency: use core
`SendMessageAction` with `messagingProviderName = "qbitEmail"` (or the host's
configured bridge name). Use the API package for templates, idempotency, links and
lookups. Both routes enqueue; neither sends inline.
## 3. Providers and transports
### 3.1 `email_provider`
+109 -46
View File
@@ -1,8 +1,8 @@
# Durable job email effects
This implements the email-side contract for QRun-IO/qqq#1191, spec section 2.13,
without importing core jobs types. QQQ stays at released 4.1.0-RC.5. The
`EmailJobEffectTarget implements EffectTarget` binding is deferred until RC.7 ships.
QQQ 4.1.0-RC.7 supplies the durable jobs side-effects outbox. QBit Email registers
`com.kingsrook.qbits.email.outbox.EmailJobEffectTarget` as target `email` to hand
those effects to its own committed outbox. All provider sending remains asynchronous.
## Stored identity and schema upgrade
@@ -50,16 +50,18 @@ and [SES header replacement](https://docs.aws.amazon.com/ses/latest/APIReference
## Handoff and reconcile
`EmailEffects.send(request, effectIdempotencyKey)` sets the request's key, refusing
a different pre-existing key, a missing/blank/overlength effect key, a null request
`EmailApi.sendOnce(EmailApiRequest request, String effectKey)` sets the request's key, refusing
a different pre-existing key, a missing/blank/over-500-character effect key, a null request
or a caller transaction. The service performs its usual validation and send
permission checks. Success is returned only after the outbox transaction commits;
there is no transport call here. Use ordinary `EmailService.send` for caller-owned
or cross-backend deferred transactions.
there is no transport call here. Use ordinary `EmailApi.send` with
`request.withTransaction(transaction, backendName)` for caller-owned or cross-backend
deferred transactions. `sendOnce` refuses either kind of caller transaction.
`EmailEffects.Result(outcome, message)` contains an `EmailEffectOutcome` and a
nullable `EmailMessageRef(Integer id, String status, String rfcMessageId,
String providerMessageId)`:
`EmailApiEffectResult(outcome, message)` contains an `EmailApiEffectOutcome` and a
nullable `EmailApiMessage` with the outbox id, current delivery status, RFC Message-ID,
provider reference and other metadata. All these types are in
`com.kingsrook.qbits.email.api`, the sole public qbit-email surface:
- `QUEUED_OR_DUPLICATE`: one committed row accepted the handoff, including replays.
- `REFUSED`: invalid or suppressed request. A suppressed audit row has a reference;
@@ -68,11 +70,15 @@ String providerMessageId)`:
- Database/query failures throw `QException`; callers must retry or reconcile,
never turn an uncertain outcome into a permanent refusal.
`findByIdempotencyKey(key)` and `findByRfcMessageId(messageId)` return
`Optional<EmailMessageRef>` from system reads, like other internal outbox reads.
`EmailApi.getMessageByIdempotencyKey(key)` and `EmailApi.getMessageByRfcMessageId(messageId)`
return `Optional<EmailApiMessage>` from system reads of an explicit metadata projection.
They return empty for null/blank/missing values. These are trusted backend APIs,
not viewer-scoped public endpoints. Lookups reflect current status and provider
reference and do not expose bodies, recipients or variables.
reference and do not expose bodies, recipients or variables. `EmailApi.getMessage(id)`
and `EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, limit)` return the same projection,
including `rfcMessageId`. Callers must authorize exposing this metadata to their users.
`EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internal
implementation types; consuming qbits use `EmailApi`.
The effect's `SENT` means successful durable handoff, not mailbox delivery. The
email dispatcher owns later delivery/retry failures. Reconcile acknowledges an
@@ -80,44 +86,101 @@ existing outbox row (including an audit row); it must never create another email
merely because that row is terminal. Retain message rows and idempotency keys for
at least the maximum effect retry/reconciliation horizon.
## RC.7 binding design note (not compiled)
## Register, declare and emit
Mapping: `deliver -> QUEUED_OR_DUPLICATE => SENT(ref.id)`, `REFUSED => FAILED`;
`reconcile -> findByIdempotencyKey present => FOUND(ref.id), absent => NOT_FOUND`.
Exceptions escape to core's retry/unknown handling. Payload decoding must preserve
the existing PHI/variable policies and must not attach a caller transaction.
This 25-line sketch is documentation-only. Match core names and signatures to the
published RC.7 contract when it ships; `decode` represents payload conversion.
Enable durable jobs **before** producing the email qbit, so the producer can register
its target automatically:
```java
// Future RC.7 binding; no core jobs imports in the RC.5 implementation.
final class EmailJobEffectTarget implements EffectTarget
{
// SENT acknowledges the committed qbit outbox row.
@Override
public DeliveryResult deliver(Effect effect) throws Exception
{
EmailRequest request = decode(effect.payload());
EmailEffects.Result result = EmailEffects.send(request, effect.idempotencyKey());
if(result.outcome() == EmailEffectOutcome.REFUSED)
{
return (DeliveryResult.failed("Email refused"));
}
return (DeliveryResult.sent(result.message().id().toString()));
}
// Existence reconciles handoff independently of mailbox delivery.
@Override
public ReconcileResult reconcile(Effect effect) throws Exception
{
return (EmailEffects.findByIdempotencyKey(effect.idempotencyKey())
.map(ref -> ReconcileResult.found(ref.id().toString()))
.orElseGet(ReconcileResult::notFound));
}
}
instance.withSupplementalMetaData(new DurableJobsMetaData());
new DurableJobsMetaDataProvider().defineAll(instance, "rdbms", null);
new EmailQBitProducer()
.withQBitConfig(new EmailQBitConfig().withBackendName("rdbms"))
.produce(instance);
```
Apply core's jobs database schema as usual. `EmailJobEffectTarget.register(instance)`
is a no-op without `DurableJobsMetaData`. If jobs are enabled after producing email,
call this helper explicitly after enabling them.
Declare the effect on the process's `DurableProcessMetaData` (retain any existing
durable settings). Replace `<step>` with the process's real backend step name:
```java
process.withSupplementalMetaData(new DurableProcessMetaData()
.withEffect(new JobEffectDeclaration("welcomeEmail", "<step>", "email",
JobEffectType.IDEMPOTENT_EXTERNAL)));
```
Then emit from that step using its `RunBackendStepInput input` and a stable row key:
```java
Map<String, Serializable> payload = new LinkedHashMap<>();
payload.put("to", "person@example.com");
payload.put("subject", "Welcome");
payload.put("textBody", "Your account is ready.");
JobEffects.emit(input, "welcomeEmail", rowKey, payload);
```
The jobs classes above come from `com.kingsrook.qqq.backend.core.jobs` and its
`effects` package; `Map`, `LinkedHashMap` and `Serializable` are JDK types.
Core stores the effect with the chunk's progress and dispatches after commit.
Outside a durable job, `JobEffects.emit` calls the target immediately with a fresh
key, still only enqueueing email; a result other than SENT throws.
Use **IDEMPOTENT_EXTERNAL**: qbit-email deduplicates by the effect's exact key,
while its own delivery outbox reuses the stored Message-ID on provider attempts.
This acknowledges one durable handoff, not exactly-once delivery to a mailbox.
## Payload contract
Unknown keys are ignored. Recognized values must have the following types:
| Keys | Values |
|---|---|
| `to`, `cc`, `bcc` | A String mailbox or a list of String mailboxes; omitted means empty. Comma-separated strings are not split. |
| `subject`, `templateName` | Strings; ordinary template/ad-hoc validation applies. |
| `markdownBody`, `htmlBody`, `textBody` | String body alternatives. |
| `variables` | Map with String keys and serializable values; requires a template schema and retains the normal PHI/classification checks. |
| `category` | String enum name: `TRANSACTIONAL`, `SECURITY`, `NOTIFICATION`, or `MARKETING`. |
| `providerName`, `brandName`, `locale` | Strings; omitted locale retains `en`. |
| `from`, `replyTo` | String mailboxes, subject to ordinary sender validation. |
| `fromAddressId` | Integer identifying an enabled address for the selected provider. |
| `sendAfter` | ISO-8601 Instant String, for example `2026-10-14T12:00:00Z`. |
| `linkedTableName`, `linkedRecordId` | String table name; record id converted to String. |
The target always sets `senderQbit = "jobs"`, `senderProcess = effectName`, and
uses `EffectRequest.idempotencyKey()`. Payload keys cannot override these or attach
a transaction. `recordVariables`, `securityValues` and `payloadRef` are not decoded;
provide the supported values directly in `payload`.
## Identity and outcomes
The dispatcher invokes the target as the system user. When `jobId` is present, the
target loads the job under core's system scope and builds its owner session through
`JobSessions.forJob`. Sending runs inside `QContext.withTemporaryContext` using that
owner session, with current permissions and the intersection of current and captured
security keys. The original dispatcher context is restored even after an exception.
Without a job id, sending retains the caller's session.
- `QUEUED_OR_DUPLICATE` becomes `EffectResult.sent(outboxId)`; `providerRef` is the
String email row id, not the transport provider's reference.
- `REFUSED` becomes `EffectResult.failed(reasons)`, including suppressed replays.
- A missing job or an owner session rejected by authentication/permission validation
becomes FAILED with `The job's owner can no longer send it: ...`.
- Other exceptions, including payload decoding and database failures, become UNKNOWN.
Error text omits raw exception details to avoid exposing payloads or database values.
Core may resend IDEMPOTENT_EXTERNAL effects using the same key.
- Reconcile uses `EmailEffects.findByIdempotencyKey`: any existing row becomes FOUND
with its id, otherwise NOT_FOUND. Lookup failures propagate rather than falsely
reporting absence. Reconcile never creates mail or changes the owner session.
The binding uses `EmailEffects` internally; other consuming qbits continue to use
`EmailApi`. The H2 target tests install core jobs metadata with `defineAll`, create
all columns of the `jobs_job` lookup table, and observe the owner through an insert
customizer because email messages have no built-in created-by/user field. No
production job-lookup test seam is needed.
Tests use H2, the existing GreenMail SMTP sink, fake SES and HTTP request seams;
no vendor endpoints are called. The PostgreSQL migration is supplied for host
deployment and is not executed by the offline Java test gate.
+89
View File
@@ -0,0 +1,89 @@
-- qbit-email: add the foreign keys to an existing PostgreSQL schema (one
-- created from email-postgres.sql before they were declared there). Pause
-- enqueue, dispatch and webhook processing while it runs.
--
-- Safe to run more than once: a foreign key that already exists with the
-- right ON DELETE (under any name) is left alone. Before each key is added,
-- rows that point at a missing parent are recorded in qqq_fk_quarantine and
-- then set to NULL (nullable column) or deleted (NOT NULL column; the whole
-- row is kept in row_data). An existing key on the column with a different
-- ON DELETE is replaced by the named one.
--
-- Order matters: sender addresses, attempts and events of a missing parent are
-- removed before the references to them (message.from_address_id,
-- suppression.source_event_id) are checked.
BEGIN;
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE OR REPLACE FUNCTION pg_temp.qqq_add_fk(p_table TEXT, p_column TEXT, p_parent TEXT, p_on_delete TEXT) RETURNS VOID LANGUAGE plpgsql AS
$fn$
DECLARE
v_name TEXT := 'fk_' || p_table || '_' || p_column;
v_rule "char" := CASE p_on_delete WHEN 'CASCADE' THEN 'c' WHEN 'SET NULL' THEN 'n' WHEN 'RESTRICT' THEN 'r' ELSE 'a' END;
v_nullable BOOLEAN;
v_changeset TEXT := 'qbit-email:email-foreign-keys-upgrade.sql';
v_old RECORD;
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column
AND c.confrelid = p_parent::regclass AND c.confdeltype = v_rule) THEN
RETURN;
END IF;
FOR v_old IN SELECT c.conname FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column LOOP
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', p_table, v_old.conname);
END LOOP;
SELECT is_nullable = 'YES' INTO v_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = p_table AND column_name = p_column;
IF v_nullable THEN
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) '
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''NULLED'', %L FROM %I t WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
p_table, p_column, p_column, v_changeset, p_table, p_column, p_parent, p_column);
EXECUTE format('UPDATE %I t SET %I = NULL WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
p_table, p_column, p_column, p_parent, p_column);
ELSE
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) '
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''DELETED'', row_to_json(t)::TEXT, %L FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
p_table, p_column, p_column, v_changeset, p_table, p_parent, p_column);
EXECUTE format('DELETE FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)', p_table, p_parent, p_column);
END IF;
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I (id) ON DELETE %s', p_table, v_name, p_column, p_parent, p_on_delete);
END
$fn$;
SELECT pg_temp.qqq_add_fk('email_provider_sender_address', 'provider_id', 'email_provider', 'CASCADE');
SELECT pg_temp.qqq_add_fk('email_message', 'provider_id', 'email_provider', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_message', 'from_address_id', 'email_provider_sender_address', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_message', 'template_id', 'email_template', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_message', 'template_version_id', 'email_template_version', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_message', 'brand_id', 'email_brand', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_template', 'brand_id', 'email_brand', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_template', 'active_version_id', 'email_template_version', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_template', 'approved_version_id', 'email_template_version', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_template_version', 'template_id', 'email_template', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_message_attempt', 'message_id', 'email_message', 'CASCADE');
SELECT pg_temp.qqq_add_fk('email_message_attempt', 'provider_id', 'email_provider', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_event', 'message_id', 'email_message', 'CASCADE');
SELECT pg_temp.qqq_add_fk('email_event', 'provider_id', 'email_provider', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_webhook_inbox', 'provider_id', 'email_provider', 'RESTRICT');
SELECT pg_temp.qqq_add_fk('email_suppression', 'source_message_id', 'email_message', 'SET NULL');
SELECT pg_temp.qqq_add_fk('email_suppression', 'source_event_id', 'email_event', 'SET NULL');
CREATE INDEX IF NOT EXISTS email_message_from_address ON email_message (from_address_id);
CREATE INDEX IF NOT EXISTS email_message_template ON email_message (template_id);
CREATE INDEX IF NOT EXISTS email_message_template_version ON email_message (template_version_id);
CREATE INDEX IF NOT EXISTS email_message_brand ON email_message (brand_id);
CREATE INDEX IF NOT EXISTS email_template_brand ON email_template (brand_id);
CREATE INDEX IF NOT EXISTS email_template_active_version ON email_template (active_version_id);
CREATE INDEX IF NOT EXISTS email_template_approved_version ON email_template (approved_version_id);
CREATE INDEX IF NOT EXISTS email_suppression_source_message ON email_suppression (source_message_id);
CREATE INDEX IF NOT EXISTS email_suppression_source_event ON email_suppression (source_event_id);
CREATE INDEX IF NOT EXISTS email_event_provider ON email_event (provider_id);
COMMIT;
+39
View File
@@ -239,3 +239,42 @@ 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);
-- Foreign keys (named fk_<table>_<column>), added after every table exists.
-- CASCADE owned rows: a provider's sender addresses (the provider delete also removes
-- them), a message's attempts and events (message retention deletes them first).
-- RESTRICT configuration under live data: providers with messages, attempts, events or
-- webhook inbox rows (disable the provider instead); a brand used by a template;
-- a template with versions (templates with versions cannot be deleted).
-- SET NULL optional pointers: a message's sender address, template, version and brand
-- (the rendered body is kept); a template's active/approved version (the
-- cycle with email_template_version.template_id; drafts can be deleted); a
-- suppression's source message and event, which outlive message retention.
-- Existing databases: run email-foreign-keys-upgrade.sql.
ALTER TABLE email_provider_sender_address ADD CONSTRAINT fk_email_provider_sender_address_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE CASCADE;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_from_address_id FOREIGN KEY (from_address_id) REFERENCES email_provider_sender_address (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_version_id FOREIGN KEY (template_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE SET NULL;
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE RESTRICT;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_active_version_id FOREIGN KEY (active_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_approved_version_id FOREIGN KEY (approved_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_template_version ADD CONSTRAINT fk_email_template_version_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE RESTRICT;
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_message_id FOREIGN KEY (source_message_id) REFERENCES email_message (id) ON DELETE SET NULL;
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_event_id FOREIGN KEY (source_event_id) REFERENCES email_event (id) ON DELETE SET NULL;
ALTER TABLE email_webhook_inbox ADD CONSTRAINT fk_email_webhook_inbox_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
CREATE INDEX email_message_from_address ON email_message (from_address_id);
CREATE INDEX email_message_template ON email_message (template_id);
CREATE INDEX email_message_template_version ON email_message (template_version_id);
CREATE INDEX email_message_brand ON email_message (brand_id);
CREATE INDEX email_template_brand ON email_template (brand_id);
CREATE INDEX email_template_active_version ON email_template (active_version_id);
CREATE INDEX email_template_approved_version ON email_template (approved_version_id);
CREATE INDEX email_suppression_source_message ON email_suppression (source_message_id);
CREATE INDEX email_suppression_source_event ON email_suppression (source_event_id);
CREATE INDEX email_event_provider ON email_event (provider_id);
+21 -1
View File
@@ -134,6 +134,26 @@
<version>${h2.version}</version>
<scope>test</scope>
</dependency>
<!-- the foreign key guardrail test: docs/ddl on a real PostgreSQL (src/test only) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<!-- EmailDefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -241,7 +261,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -38,6 +38,7 @@ 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.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
@@ -196,7 +197,13 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
output.add(EmailApp.app(qBitConfig, tables));
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
// Registration is metadata-only; core's startup sync guards hosts without helpContent.
HelpBundles.register(qInstance, "help/qbit-email.yaml");
HelpBundles.of(qInstance).getBundles().stream().filter(bundle -> "qbit-email".equals(bundle.getId()))
.flatMap(bundle -> bundle.getEntries().stream()).filter(entry -> "app:email;slot:help".equals(entry.getKey()))
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
registerWebhooks(qInstance);
com.kingsrook.qbits.email.outbox.EmailJobEffectTarget.register(qInstance);
return (new MetaDataProducerMultiOutput());
}
@@ -0,0 +1,429 @@
/*
* 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.api;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailEffects;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.support.EmailApiMapper;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.template.VariableSchema;
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.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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Stable contract for other qbits. Changes within a 0.x minor release line are
** additive only; implementation packages are not API and may change.
** Database operations use the current QContext. Reads run as the email system
** and return only the documented projection; callers must authorize any
** exposure of this metadata to their own users. Sends retain all existing
** permission, validation, PHI, suppression and recipient checks.
*******************************************************************************/
public final class EmailApi
{
private static final Set<String> MESSAGE_FIELDS = Set.of("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId",
"templateId", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailApi()
{
}
/*******************************************************************************
** True only when the email qbit was produced in the supplied instance.
** Does not require a current context; null is not installed.
*******************************************************************************/
public static boolean isInstalled(QInstance instance)
{
return (EmailRuntime.config(instance) != null);
}
/*******************************************************************************
** Queue a message. QUEUED is not a delivery acknowledgement. Null requests
** are refused by the same service used by core messaging.
*******************************************************************************/
public static EmailApiResult send(EmailApiRequest request) throws QException
{
return (EmailApiMapper.result(EmailService.send(EmailApiMapper.request(request))));
}
/*******************************************************************************
** Commit one durable handoff with the effect key as the idempotency key.
** Refuse null requests, blank or over-500-character keys, conflicting keys
** and caller transactions before changing the request. Suppression is REFUSED,
** including replays; a retained audit row is returned. Success acknowledges
** committed storage, not delivery. Database failures propagate as QException.
*******************************************************************************/
public static EmailApiEffectResult sendOnce(EmailApiRequest request, String effectKey) throws QException
{
if(request == null || effectKey == null || effectKey.isBlank() || effectKey.length() > 500
|| request.getTransaction() != null
|| request.getIdempotencyKey() != null && !request.getIdempotencyKey().equals(effectKey))
{
return (new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
}
request.withIdempotencyKey(effectKey);
var result = EmailEffects.send(EmailApiMapper.request(request), effectKey);
EmailApiMessage message = result.message() == null ? null : getMessage(result.message().id())
.orElseThrow(() -> new QException("Queued email reference is unavailable"));
return (new EmailApiEffectResult(EmailApiEffectOutcome.valueOf(result.outcome().name()), message));
}
/*******************************************************************************
** Create a validated template and active version once. Returns false when
** it already exists. Invalid definitions throw IllegalArgumentException.
** Does not grant PHI approval or overwrite administrator edits.
*******************************************************************************/
public static boolean ensureTemplate(EmailApiTemplate template) throws QException
{
return (EmailTemplates.ensure(EmailApiMapper.template(template)));
}
/*******************************************************************************
** System lookup of delivery metadata. Null and unknown ids return empty.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessage(Integer messageId) throws QException
{
if(messageId == null)
{
return (Optional.empty());
}
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", messageId).withLimit(1), MESSAGE_FIELDS);
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
}
/*******************************************************************************
** System lookup by exact effect or application key. Null, blank and unknown
** keys return empty. Returns current metadata without message content.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessageByIdempotencyKey(String key) throws QException
{
return (findMessage("idempotencyKey", key));
}
/*******************************************************************************
** System lookup by the exact bracketed RFC Message-ID stored at enqueue time.
** Null, blank and unknown ids return empty; provider response ids are distinct.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessageByRfcMessageId(String rfcMessageId) throws QException
{
return (findMessage("rfcMessageId", rfcMessageId));
}
/*******************************************************************************
** Share exact-key lookup semantics and the metadata-only projection.
*******************************************************************************/
private static Optional<EmailApiMessage> findMessage(String field, String value) throws QException
{
if(value == null || value.isBlank())
{
return (Optional.empty());
}
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal(field, value).withLimit(1), MESSAGE_FIELDS);
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
}
/*******************************************************************************
** System lookup by literal prefix, ordered by ascending message id. Prefix
** must be nonblank and limit positive; invalid arguments are rejected.
** Returns at most limit rows, without message content or recipient data.
*******************************************************************************/
public static List<EmailApiMessage> findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException
{
if(prefix == null || prefix.isBlank() || limit < 1)
{
throw (new IllegalArgumentException("A nonblank idempotency key prefix and a positive limit are required"));
}
///////////////////////////////////////////////////////////////////////////
// QQQ SQL STARTS_WITH uses LIKE without escaping. Narrow by the literal //
// portion before any wildcard, then check the full prefix in Java. Page //
// by id so wildcard false positives cannot consume the requested limit. //
///////////////////////////////////////////////////////////////////////////
String candidatePrefix = prefix.split("[%_\\\\]", 2)[0];
List<EmailApiMessage> messages = new ArrayList<>();
int lastId = 0;
while(messages.size() < limit)
{
int batchSize = Math.min(500, limit - messages.size());
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, candidatePrefix))
.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId))
.withOrderBy(new QFilterOrderBy("id")).withLimit(batchSize);
List<QRecord> rows = read(EmailTables.MESSAGE, filter, MESSAGE_FIELDS);
for(QRecord row : rows)
{
lastId = row.getValueInteger("id");
if(row.getValueString("idempotencyKey") != null && row.getValueString("idempotencyKey").startsWith(prefix))
{
messages.add(message(row));
}
}
if(rows.size() < batchSize)
{
break;
}
}
return (List.copyOf(messages));
}
/*******************************************************************************
** Enabled sender rows of an enabled named provider, ordered by id. Null
** selects the effective default provider. No match returns an empty list.
** The provider's legacy from-address fallback is not a sender row.
*******************************************************************************/
public static List<EmailApiSenderAddress> listSenderAddresses(String providerName) throws QException
{
QRecord provider = provider(QContext.getQInstance(), providerName);
if(provider == null)
{
return (List.of());
}
QQueryFilter filter = EmailStore.equal("providerId", provider.getValueInteger("id"))
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id"));
return (read(EmailTables.SENDER, filter, Set.of("id", "address", "displayName", "isDefault"))
.stream().map(row -> new EmailApiSenderAddress(row.getValueInteger("id"), row.getValueString("address"),
row.getValueString("displayName"), Boolean.TRUE.equals(row.getValueBoolean("isDefault")))).toList());
}
/*******************************************************************************
** Effective enabled default: the configured name takes precedence over the
** default row. Empty when absent, disabled or ambiguous. An installed
** instance must be the current QContext instance, otherwise this throws.
*******************************************************************************/
public static Optional<String> defaultProviderName(QInstance instance) throws QException
{
QRecord provider = provider(instance, null);
return (provider == null ? Optional.empty() : Optional.of(provider.getValueString("name")));
}
/*******************************************************************************
** Preflight the default sending configuration for a category. Null category
** means TRANSACTIONAL. This does not validate a particular template,
** recipient or permission, or contact a transport. PHI callers use the
** overload with phiApproved set to true.
*******************************************************************************/
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category) throws QException
{
return (readiness(instance, category, false));
}
/*******************************************************************************
** Also check prerequisites for a PHI-approved template. PHI is a template
** property, not an email category. Checks body key availability and provider
** PHI/tracking policy; does not approve a template. Resolver failures are
** reported as missing keys without exposing exception details or values.
*******************************************************************************/
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category, boolean phiApproved) throws QException
{
List<String> reasons = new ArrayList<>();
Set<EmailApiReadinessCheck> failed = EnumSet.noneOf(EmailApiReadinessCheck.class);
var config = EmailRuntime.config(instance);
if(config == null)
{
return (new EmailApiReadiness(false, List.of("Email qbit is not installed"), Set.of(EmailApiReadinessCheck.NOT_INSTALLED)));
}
QRecord provider = provider(instance, null);
if(provider == null)
{
reasons.add("An enabled named or default provider is required");
failed.add(EmailApiReadinessCheck.PROVIDER_MISSING);
}
else
{
if(!EmailTableCustomizers.validateProvider(provider, config).isEmpty())
{
reasons.add("The default provider configuration is invalid");
failed.add(EmailApiReadinessCheck.PROVIDER_INVALID);
}
if(phiApproved)
{
try
{
EmailTemplateRenderer.checkProvider(provider);
}
catch(IllegalArgumentException e)
{
reasons.add("PHI email needs a PHI-enabled provider with open and click tracking disabled");
failed.add(EmailApiReadinessCheck.PHI_PROVIDER);
}
}
}
if(category == EmailApiCategory.MARKETING)
{
try
{
if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank())
{
throw (new IllegalArgumentException());
}
VariableSchema.url(config.getPublicBaseUrl());
}
catch(IllegalArgumentException e)
{
reasons.add("MARKETING email needs a valid publicBaseUrl");
failed.add(EmailApiReadinessCheck.PUBLIC_BASE_URL);
}
if(!hasSecret(config.getSecretResolver(), "QQQ_EMAIL_UNSUBSCRIBE_KEY"))
{
reasons.add("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY");
failed.add(EmailApiReadinessCheck.UNSUBSCRIBE_KEY);
}
List<QRecord> brands = read(EmailTables.BRAND, EmailStore.equal("isDefault", true).withLimit(2), Set.of("id", "postalAddress"));
if(brands.size() != 1 || brands.getFirst().getValueString("postalAddress") == null || brands.getFirst().getValueString("postalAddress").isBlank())
{
reasons.add("MARKETING email needs a default brand with a postal address");
failed.add(EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS);
}
}
if(phiApproved && !hasSecret(config.getSecretResolver(), "QQQ_EMAIL_BODY_KEY"))
{
reasons.add("PHI email needs QQQ_EMAIL_BODY_KEY");
failed.add(EmailApiReadinessCheck.BODY_KEY);
}
return (new EmailApiReadiness(reasons.isEmpty(), reasons, failed));
}
/*******************************************************************************
** Presence only; neither retain values nor expose resolver failures.
*******************************************************************************/
private static boolean hasSecret(Function<String, String> resolver, String name)
{
try
{
String value = resolver.apply(name);
return (value != null && !value.isBlank());
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** Match the enqueue service's configured-name/default-row precedence.
** Refuse a mismatched context instead of reading another instance's data.
*******************************************************************************/
private static QRecord provider(QInstance instance, String name) throws QException
{
var config = EmailRuntime.config(instance);
if(config == null)
{
return (null);
}
if(instance != QContext.getQInstance())
{
throw (new IllegalArgumentException("The supplied instance must be the current QContext instance"));
}
String effectiveName = name == null ? config.getDefaultProviderName() : name;
QQueryFilter filter = EmailStore.equal("enabled", true)
.withCriteria(new QFilterCriteria(effectiveName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, effectiveName == null ? true : effectiveName))
.withLimit(2);
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, filter);
return (providers.size() == 1 ? providers.getFirst() : null);
}
/*******************************************************************************
** Read an explicit projection; message bodies are never fetched here.
*******************************************************************************/
private static List<QRecord> read(String table, QQueryFilter filter, Set<String> fields) throws QException
{
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
}
/*******************************************************************************
** Resolve the template name and copy only the approved metadata fields.
*******************************************************************************/
private static EmailApiMessage message(QRecord row) throws QException
{
String templateName = null;
if(row.getValueInteger("templateId") != null)
{
List<QRecord> templates = read(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValueInteger("templateId")).withLimit(1), Set.of("id", "name"));
if(!templates.isEmpty())
{
templateName = templates.getFirst().getValueString("name");
}
}
String category = row.getValueString("category");
return (new EmailApiMessage(row.getValueInteger("id"), row.getValueString("status"), category == null ? null : EmailApiCategory.valueOf(category),
row.getValueString("idempotencyKey"), row.getValueString("rfcMessageId"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"),
row.getValueString("linkedRecordId"), row.getValueInstant("createDate"), row.getValueInstant("sentDate"), row.getValueInstant("deliveredDate")));
}
}
@@ -0,0 +1,33 @@
/*
* 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.api;
/*******************************************************************************
** Stable sending categories; PHI approval belongs to the template, not the category.
*******************************************************************************/
public enum EmailApiCategory
{
TRANSACTIONAL,
NOTIFICATION,
MARKETING,
SECURITY
}
@@ -0,0 +1,31 @@
/*
* 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.api;
/*******************************************************************************
** Durable handoff outcomes. Operational failures propagate to the caller.
*******************************************************************************/
public enum EmailApiEffectOutcome
{
QUEUED_OR_DUPLICATE,
REFUSED
}
@@ -0,0 +1,30 @@
/*
* 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.api;
/*******************************************************************************
** Durable handoff result. The message is null when no outbox row was retained.
** Suppressed audit rows carry metadata even though the outcome is REFUSED.
*******************************************************************************/
public record EmailApiEffectResult(EmailApiEffectOutcome outcome, EmailApiMessage message)
{
}
@@ -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.api;
import java.time.Instant;
/*******************************************************************************
** Immutable delivery metadata. No addresses, subject, bodies or variable snapshots.
** Status is the persisted delivery state, distinct from EmailApiStatus enqueue outcomes.
*******************************************************************************/
public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String rfcMessageId, String providerMessageId,
String templateName, String linkedTableName, String linkedRecordId, Instant createDate, Instant sentDate, Instant deliveredDate)
{
}
@@ -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.api;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/*******************************************************************************
** Configuration preflight, not a delivery guarantee. Reasons never contain secret values.
*******************************************************************************/
public record EmailApiReadiness(boolean ready, List<String> reasons, Set<EmailApiReadinessCheck> failedChecks)
{
/*******************************************************************************
** Copy configuration reasons.
*******************************************************************************/
public EmailApiReadiness
{
reasons = List.copyOf(reasons);
failedChecks = failedChecks == null || failedChecks.isEmpty() ? Set.of() : Set.copyOf(EnumSet.copyOf(failedChecks));
}
/*******************************************************************************
** Whether a specific prerequisite failed.
*******************************************************************************/
public boolean failed(EmailApiReadinessCheck check)
{
return (failedChecks.contains(check));
}
}
@@ -0,0 +1,38 @@
/*
* 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.api;
/*******************************************************************************
** One readiness prerequisite, so callers can react to specific gaps without
** parsing reason text.
*******************************************************************************/
public enum EmailApiReadinessCheck
{
NOT_INSTALLED,
PROVIDER_MISSING,
PROVIDER_INVALID,
PHI_PROVIDER,
PUBLIC_BASE_URL,
UNSUBSCRIBE_KEY,
BRAND_POSTAL_ADDRESS,
BODY_KEY
}
@@ -0,0 +1,670 @@
/*
* 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.api;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
/*******************************************************************************
** Stable enqueue request. One send always goes through the outbox.
** Null category means TRANSACTIONAL for ad-hoc mail; templates select their category.
** Recipient lists and variable maps are mutable request-building state.
*******************************************************************************/
public final class EmailApiRequest
{
private List<String> to = new ArrayList<>();
private List<String> cc = new ArrayList<>();
private List<String> bcc = new ArrayList<>();
private String from;
private Integer fromAddressId;
private String replyTo;
private String subject;
private String markdownBody;
private String htmlBody;
private String textBody;
private String providerName;
private String templateName;
private String brandName;
private String locale = "en";
private EmailApiCategory category;
private String idempotencyKey;
private QBackendTransaction transaction;
private String transactionBackendName;
private Instant sendAfter;
private String linkedTableName;
private String linkedRecordId;
private String senderQbit;
private String senderProcess;
private Map<String, Serializable> variables = new LinkedHashMap<>();
private Map<String, RecordVariable> recordVariables = new LinkedHashMap<>();
/*******************************************************************************
** Getter for to.
*******************************************************************************/
public List<String> getTo()
{
return (to);
}
/*******************************************************************************
** Fluent setter for to.
*******************************************************************************/
public EmailApiRequest withTo(List<String> to)
{
this.to = to;
return (this);
}
/*******************************************************************************
** Getter for cc.
*******************************************************************************/
public List<String> getCc()
{
return (cc);
}
/*******************************************************************************
** Fluent setter for cc.
*******************************************************************************/
public EmailApiRequest withCc(List<String> cc)
{
this.cc = cc;
return (this);
}
/*******************************************************************************
** Getter for bcc.
*******************************************************************************/
public List<String> getBcc()
{
return (bcc);
}
/*******************************************************************************
** Fluent setter for bcc.
*******************************************************************************/
public EmailApiRequest withBcc(List<String> bcc)
{
this.bcc = bcc;
return (this);
}
/*******************************************************************************
** Getter for from.
*******************************************************************************/
public String getFrom()
{
return (from);
}
/*******************************************************************************
** Fluent setter for from.
*******************************************************************************/
public EmailApiRequest withFrom(String from)
{
this.from = from;
return (this);
}
/*******************************************************************************
** Getter for fromAddressId.
*******************************************************************************/
public Integer getFromAddressId()
{
return (fromAddressId);
}
/*******************************************************************************
** Fluent setter for fromAddressId.
*******************************************************************************/
public EmailApiRequest withFromAddressId(Integer fromAddressId)
{
this.fromAddressId = fromAddressId;
return (this);
}
/*******************************************************************************
** Getter for replyTo.
*******************************************************************************/
public String getReplyTo()
{
return (replyTo);
}
/*******************************************************************************
** Fluent setter for replyTo.
*******************************************************************************/
public EmailApiRequest withReplyTo(String replyTo)
{
this.replyTo = replyTo;
return (this);
}
/*******************************************************************************
** Getter for subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Fluent setter for subject.
*******************************************************************************/
public EmailApiRequest withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Getter for markdownBody.
*******************************************************************************/
public String getMarkdownBody()
{
return (markdownBody);
}
/*******************************************************************************
** Fluent setter for markdownBody.
*******************************************************************************/
public EmailApiRequest withMarkdownBody(String markdownBody)
{
this.markdownBody = markdownBody;
return (this);
}
/*******************************************************************************
** Getter for htmlBody.
*******************************************************************************/
public String getHtmlBody()
{
return (htmlBody);
}
/*******************************************************************************
** Fluent setter for htmlBody.
*******************************************************************************/
public EmailApiRequest withHtmlBody(String htmlBody)
{
this.htmlBody = htmlBody;
return (this);
}
/*******************************************************************************
** Getter for textBody.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** Fluent setter for textBody.
*******************************************************************************/
public EmailApiRequest withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** Getter for providerName.
*******************************************************************************/
public String getProviderName()
{
return (providerName);
}
/*******************************************************************************
** Fluent setter for providerName.
*******************************************************************************/
public EmailApiRequest withProviderName(String providerName)
{
this.providerName = providerName;
return (this);
}
/*******************************************************************************
** Getter for templateName.
*******************************************************************************/
public String getTemplateName()
{
return (templateName);
}
/*******************************************************************************
** Fluent setter for templateName.
*******************************************************************************/
public EmailApiRequest withTemplateName(String templateName)
{
this.templateName = templateName;
return (this);
}
/*******************************************************************************
** Getter for brandName.
*******************************************************************************/
public String getBrandName()
{
return (brandName);
}
/*******************************************************************************
** Fluent setter for brandName.
*******************************************************************************/
public EmailApiRequest withBrandName(String brandName)
{
this.brandName = brandName;
return (this);
}
/*******************************************************************************
** Getter for locale.
*******************************************************************************/
public String getLocale()
{
return (locale);
}
/*******************************************************************************
** Fluent setter for locale.
*******************************************************************************/
public EmailApiRequest withLocale(String locale)
{
this.locale = locale;
return (this);
}
/*******************************************************************************
** Getter for category.
*******************************************************************************/
public EmailApiCategory getCategory()
{
return (category);
}
/*******************************************************************************
** Fluent setter for category.
*******************************************************************************/
public EmailApiRequest withCategory(EmailApiCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Fluent setter for idempotencyKey.
*******************************************************************************/
public EmailApiRequest withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Getter for the optional caller transaction. sendOnce refuses this option.
*******************************************************************************/
public QBackendTransaction getTransaction()
{
return (transaction);
}
/*******************************************************************************
** Getter for the declared backend of the caller transaction.
*******************************************************************************/
public String getTransactionBackendName()
{
return (transactionBackendName);
}
/*******************************************************************************
** Join a caller transaction for ordinary send; another backend defers enqueue
** until commit. Declare its backend because QBackendTransaction does not expose
** it. sendOnce refuses any caller transaction to guarantee committed handoff.
*******************************************************************************/
public EmailApiRequest withTransaction(QBackendTransaction transaction, String backendName)
{
this.transaction = transaction;
this.transactionBackendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for sendAfter.
*******************************************************************************/
public Instant getSendAfter()
{
return (sendAfter);
}
/*******************************************************************************
** Fluent setter for sendAfter.
*******************************************************************************/
public EmailApiRequest withSendAfter(Instant sendAfter)
{
this.sendAfter = sendAfter;
return (this);
}
/*******************************************************************************
** Getter for linkedTableName.
*******************************************************************************/
public String getLinkedTableName()
{
return (linkedTableName);
}
/*******************************************************************************
** Fluent setter for linkedTableName.
*******************************************************************************/
public EmailApiRequest withLinkedTableName(String linkedTableName)
{
this.linkedTableName = linkedTableName;
return (this);
}
/*******************************************************************************
** Getter for linkedRecordId.
*******************************************************************************/
public String getLinkedRecordId()
{
return (linkedRecordId);
}
/*******************************************************************************
** Fluent setter for linkedRecordId.
*******************************************************************************/
public EmailApiRequest withLinkedRecordId(String linkedRecordId)
{
this.linkedRecordId = linkedRecordId;
return (this);
}
/*******************************************************************************
** Getter for senderQbit.
*******************************************************************************/
public String getSenderQbit()
{
return (senderQbit);
}
/*******************************************************************************
** Fluent setter for senderQbit.
*******************************************************************************/
public EmailApiRequest withSenderQbit(String senderQbit)
{
this.senderQbit = senderQbit;
return (this);
}
/*******************************************************************************
** Getter for senderProcess.
*******************************************************************************/
public String getSenderProcess()
{
return (senderProcess);
}
/*******************************************************************************
** Fluent setter for senderProcess.
*******************************************************************************/
public EmailApiRequest withSenderProcess(String senderProcess)
{
this.senderProcess = senderProcess;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public Map<String, Serializable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public EmailApiRequest withVariables(Map<String, Serializable> variables)
{
this.variables = variables;
return (this);
}
/*******************************************************************************
** Getter for recordVariables.
*******************************************************************************/
public Map<String, RecordVariable> getRecordVariables()
{
return (recordVariables);
}
/*******************************************************************************
** Fluent setter for recordVariables.
*******************************************************************************/
public EmailApiRequest withRecordVariables(Map<String, RecordVariable> recordVariables)
{
this.recordVariables = recordVariables;
return (this);
}
/*******************************************************************************
** Append to recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withTo(String... addresses)
{
to.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append cc recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withCc(String... addresses)
{
cc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append bcc recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withBcc(String... addresses)
{
bcc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Link the message to an application record.
*******************************************************************************/
public EmailApiRequest withLinkedRecord(String table, Object id)
{
linkedTableName = table;
linkedRecordId = id == null ? null : String.valueOf(id);
return (this);
}
/*******************************************************************************
** Identify the producing qbit and process.
*******************************************************************************/
public EmailApiRequest withSender(String qbit, String process)
{
senderQbit = qbit;
senderProcess = process;
return (this);
}
/*******************************************************************************
** Bind one variable; template schema and classification checks still apply.
*******************************************************************************/
public EmailApiRequest withVariable(String name, Serializable value)
{
variables.put(name, value);
return (this);
}
/*******************************************************************************
** Bind record provenance without reading or snapshotting the record.
*******************************************************************************/
public EmailApiRequest withRecordVariable(String name, String table, Serializable pk)
{
recordVariables.put(name, new RecordVariable(table, pk));
return (this);
}
/*******************************************************************************
** A record reference; values are resolved and classified by the sending service.
*******************************************************************************/
public record RecordVariable(String table, Serializable id)
{
}
}
@@ -0,0 +1,39 @@
/*
* 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.api;
import java.util.List;
/*******************************************************************************
** Immutable enqueue result with plain refusal reasons.
*******************************************************************************/
public record EmailApiResult(Integer messageId, EmailApiStatus status, List<String> reasons)
{
/*******************************************************************************
** Copy reasons so callers cannot mutate an outcome.
*******************************************************************************/
public EmailApiResult
{
reasons = List.copyOf(reasons);
}
}
@@ -0,0 +1,29 @@
/*
* 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.api;
/*******************************************************************************
** An enabled provider sender address; no provider credentials.
*******************************************************************************/
public record EmailApiSenderAddress(Integer id, String address, String displayName, boolean isDefault)
{
}
@@ -0,0 +1,33 @@
/*
* 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.api;
/*******************************************************************************
** Enqueue outcomes. QUEUED acknowledges storage, not delivery.
*******************************************************************************/
public enum EmailApiStatus
{
QUEUED,
DUPLICATE,
SUPPRESSED,
REFUSED
}
@@ -0,0 +1,319 @@
/*
* 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.api;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
** A create-once template definition. Existing templates and versions are never overwritten.
** New templates are not PHI approved; approval remains a separately authorized operation.
*******************************************************************************/
public final class EmailApiTemplate
{
private String name;
private String label;
private EmailApiCategory category;
private String kind = "MARKDOWN";
private String subject;
private String body;
private String textBody;
private String variablesSchema;
private List<EmailApiVariable> variables = new ArrayList<>();
private String sampleVariables;
private String ownerQbit;
private String editPermissionName;
/*******************************************************************************
** Getter for name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Fluent setter for name.
*******************************************************************************/
public EmailApiTemplate withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Fluent setter for label.
*******************************************************************************/
public EmailApiTemplate withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for category.
*******************************************************************************/
public EmailApiCategory getCategory()
{
return (category);
}
/*******************************************************************************
** Fluent setter for category.
*******************************************************************************/
public EmailApiTemplate withCategory(EmailApiCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for kind.
*******************************************************************************/
public String getKind()
{
return (kind);
}
/*******************************************************************************
** Fluent setter for kind.
*******************************************************************************/
public EmailApiTemplate withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Fluent setter for subject.
*******************************************************************************/
public EmailApiTemplate withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Getter for body.
*******************************************************************************/
public String getBody()
{
return (body);
}
/*******************************************************************************
** Fluent setter for body.
*******************************************************************************/
public EmailApiTemplate withBody(String body)
{
this.body = body;
return (this);
}
/*******************************************************************************
** Getter for textBody.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** Fluent setter for textBody.
*******************************************************************************/
public EmailApiTemplate withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** Getter for variablesSchema.
*******************************************************************************/
public String getVariablesSchema()
{
return (variablesSchema);
}
/*******************************************************************************
** Fluent setter for variablesSchema.
*******************************************************************************/
public EmailApiTemplate withVariablesSchema(String variablesSchema)
{
this.variablesSchema = variablesSchema;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public List<EmailApiVariable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public EmailApiTemplate withVariables(List<EmailApiVariable> variables)
{
this.variables = variables;
return (this);
}
/*******************************************************************************
** Getter for sampleVariables.
*******************************************************************************/
public String getSampleVariables()
{
return (sampleVariables);
}
/*******************************************************************************
** Fluent setter for sampleVariables.
*******************************************************************************/
public EmailApiTemplate withSampleVariables(String sampleVariables)
{
this.sampleVariables = sampleVariables;
return (this);
}
/*******************************************************************************
** Getter for ownerQbit.
*******************************************************************************/
public String getOwnerQbit()
{
return (ownerQbit);
}
/*******************************************************************************
** Fluent setter for ownerQbit.
*******************************************************************************/
public EmailApiTemplate withOwnerQbit(String ownerQbit)
{
this.ownerQbit = ownerQbit;
return (this);
}
/*******************************************************************************
** Getter for editPermissionName.
*******************************************************************************/
public String getEditPermissionName()
{
return (editPermissionName);
}
/*******************************************************************************
** Fluent setter for editPermissionName.
*******************************************************************************/
public EmailApiTemplate withEditPermissionName(String editPermissionName)
{
this.editPermissionName = editPermissionName;
return (this);
}
/*******************************************************************************
** Append a typed variable declaration. Use this or variablesSchema, not both.
*******************************************************************************/
public EmailApiTemplate withVariable(EmailApiVariable variable)
{
variables.add(variable);
return (this);
}
/*******************************************************************************
** Append a variable declaration using the template schema vocabulary.
*******************************************************************************/
public EmailApiTemplate withVariable(String name, String label, String type, boolean required, String classification)
{
return (withVariable(new EmailApiVariable(name, label, type, required, classification)));
}
}
@@ -0,0 +1,30 @@
/*
* 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.api;
/*******************************************************************************
** Template variable declaration. Types: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN,
** URL, LIST, RECORD. Classification: PUBLIC, CONFIDENTIAL, PII, PHI or null.
*******************************************************************************/
public record EmailApiVariable(String name, String label, String type, boolean required, String classification)
{
}
@@ -32,6 +32,7 @@ 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.qbits.email.template.EmailBuiltIns;
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;
@@ -101,7 +102,13 @@ public final class EmailHome
provider.setValue("headroom", Math.max(0, limit - used));
}
}
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions())));
List<EmailSetupStatus.Item> setup = List.of();
if(admin)
{
EmailBuiltIns.ensure();
setup = EmailSetupStatus.load();
}
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions(), setup)));
}
/*******************************************************************************
@@ -61,8 +61,19 @@ public final class 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)
{
return (render(totals, hourly, providers, admin, messages, suppressions, List.of()));
}
/*******************************************************************************
** The same home with the Setup card first (email admins), when there are items.
*******************************************************************************/
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions, List<EmailSetupStatus.Item> setup)
{
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
setup(html, setup);
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++)
@@ -82,6 +93,46 @@ public final class EmailHomeView
/*******************************************************************************
** The Setup card: each part of Email, set up or not, and the next step, linked
** to its table.
*******************************************************************************/
private static void setup(StringBuilder html, List<EmailSetupStatus.Item> items)
{
if(items.isEmpty())
{
return;
}
section(html, "Setup");
long todo = items.stream().filter(item -> item.state() == EmailSetupStatus.State.TODO).count();
html.append("<p style=\"margin:0 0 8px;opacity:.75\">").append(todo == 0 ? "Everything Email needs is set up." : todo + (todo == 1 ? " item needs" : " items need") + " attention.").append("</p>");
for(EmailSetupStatus.Item item : items)
{
String color = switch(item.state())
{
case OK -> OK;
case TODO -> FAIL;
case DEFAULT -> "#d97706";
case OPTIONAL -> "#808080";
};
String label = switch(item.state())
{
case OK -> "Set up";
case TODO -> "Needs setup";
case DEFAULT -> "Using defaults";
case OPTIONAL -> "Optional";
};
String name = item.tableName() == null ? esc(item.label()) : "<a style=\"color:inherit\" href=\"/app/" + esc(item.tableName()) + "\">" + esc(item.label()) + "</a>";
html.append("<div style=\"").append(ROW).append("\">")
.append(cell("Part", "<strong>" + name + "</strong><br><span style=\"color:" + color + "\">" + label + "</span>"))
.append(cell("Status", esc(item.detail())))
.append(cell("Next step", esc(item.nextStep() == null ? "Nothing to do" : item.nextStep()))).append("</div>");
}
html.append("</section>");
}
/*******************************************************************************
** A bounded stacked bar chart, including exact values in each bar's title.
*******************************************************************************/
@@ -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.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiReadiness;
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
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.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The Setup card on the Email home: each configuration table, whether it is
** set up, and the next step. It uses the same resolution the sender uses (the
** default provider, the brand lookup, EmailApi.readiness), so it never reports
** a problem the sender would not have.
*******************************************************************************/
public final class EmailSetupStatus
{
/*******************************************************************************
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
** OPTIONAL: a feature that is off until set up.
*******************************************************************************/
public enum State
{
OK, TODO, DEFAULT, OPTIONAL
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailSetupStatus()
{
}
/*******************************************************************************
** Build the items (as the qbit, for an email admin).
*******************************************************************************/
public static List<Item> load() throws QException
{
EmailQBitConfig config = EmailRuntime.config();
List<Item> items = new ArrayList<>();
///////////////////////////////////////////////////////////
// the provider: the same check a send makes (readiness) //
///////////////////////////////////////////////////////////
EmailApiReadiness readiness = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL);
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter());
QRecord active = providers.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("enabled")) && Boolean.TRUE.equals(p.getValueBoolean("isDefault")))
.findFirst().orElse(null);
if(readiness.failed(EmailApiReadinessCheck.PROVIDER_MISSING))
{
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, providers.isEmpty() ? "No provider yet, so nothing can be sent." : "No enabled default provider, so nothing can be sent.",
"Add one with New (kind, host or region, from address, and the NAMES of the secret variables), turn on Default, then Send Test Email."));
}
else if(readiness.failed(EmailApiReadinessCheck.PROVIDER_INVALID))
{
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider's configuration is invalid.", "Open it and fix the highlighted settings, then Send Test Email."));
}
else if(active != null && "UNHEALTHY".equals(active.getValueString("healthStatus")))
{
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing: " + valueOr(active.getValueString("lastFailureMessage"), "see its last failure") + ".",
"Check its settings and secret variables, then Send Test Email."));
}
else
{
items.add(new Item("Provider", EmailTables.PROVIDER, State.OK, valueOr(active == null ? null : active.getValueString("label"), "The default provider") + " sends mail.", null));
}
///////////////////////////////////////////////////////////////
// sender addresses: the provider's own from address works //
///////////////////////////////////////////////////////////////
int senders = SystemWrites.count(EmailTables.SENDER, new QQueryFilter());
items.add(senders == 0
? new Item("Sender addresses", EmailTables.SENDER, State.OPTIONAL, "None: the provider's own from address is used.", "Add some if one provider sends as several mailboxes.")
: new Item("Sender addresses", EmailTables.SENDER, State.OK, senders + (senders == 1 ? " address." : " addresses."), null));
//////////////////////////////////////////////////////////////////
// brand: the lookup the renderer does (app brand, then default) //
//////////////////////////////////////////////////////////////////
List<QRecord> brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("appName", config.getAppName()));
if(brands.isEmpty())
{
brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("isDefault", true));
}
if(brands.size() > 1)
{
items.add(new Item("Brands", EmailTables.BRAND, State.TODO, "More than one default brand matches, so branded sends are refused.", "Leave Default Brand on only one."));
}
else if(brands.isEmpty())
{
items.add(new Item("Brands", EmailTables.BRAND, State.DEFAULT, "No default brand: emails use the plain neutral look.", "Copy the neutral brand, set your logo, colors and footer, and tick Default Brand."));
}
else
{
items.add(new Item("Brands", EmailTables.BRAND, State.OK, valueOr(brands.getFirst().getValueString("label"), "The default brand") + " is the default.", null));
}
///////////////
// templates //
///////////////
int templates = SystemWrites.count(EmailTables.TEMPLATE, new QQueryFilter());
items.add(templates == 0
? new Item("Templates", EmailTables.TEMPLATE, State.DEFAULT, "No template yet: plain messages can still be sent.", "Other qbits register theirs; add your own with New, then Activate Version.")
: new Item("Templates", EmailTables.TEMPLATE, State.OK, templates + (templates == 1 ? " template." : " templates."), null));
/////////////////////////////////////////////////////
// marketing: the same checks as a MARKETING send //
/////////////////////////////////////////////////////
EmailApiReadiness marketing = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING);
List<String> missing = new ArrayList<>();
if(marketing.failed(EmailApiReadinessCheck.PUBLIC_BASE_URL))
{
missing.add("a public base URL");
}
if(marketing.failed(EmailApiReadinessCheck.UNSUBSCRIBE_KEY))
{
missing.add("the QQQ_EMAIL_UNSUBSCRIBE_KEY variable");
}
if(marketing.failed(EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS))
{
missing.add("a default brand with a postal address");
}
items.add(missing.isEmpty()
? new Item("Marketing email", EmailTables.BRAND, State.OK, "Ready: unsubscribe links, base URL and postal address are set.", null)
: new Item("Marketing email", EmailTables.BRAND, State.OPTIONAL, "Off until set up: needs " + String.join(", ", missing) + ".", "Only needed if you send marketing email."));
//////////////////////////////////////////////////////////
// delivery tracking: the provider's webhook secret name //
//////////////////////////////////////////////////////////
boolean webhook = active != null && valueOr(active.getValueString("webhookSecretEnvName"), "").length() > 0;
int inbox = SystemWrites.count(EmailTables.INBOX, new QQueryFilter());
items.add(webhook
? new Item("Delivery tracking", EmailTables.INBOX, State.OK, inbox == 0 ? "A webhook secret is set; no callback has arrived yet." : inbox + " provider callbacks received.", null)
: new Item("Delivery tracking", EmailTables.INBOX, State.OPTIONAL, "Off: the default provider has no webhook secret variable, so bounces and complaints are not tracked.",
"Set the provider's webhook secret variable and point the provider at " + config.getWebhookBasePath() + "."));
int suppressed = SystemWrites.count(EmailTables.SUPPRESSION, new QQueryFilter());
items.add(new Item("Suppression list", EmailTables.SUPPRESSION, State.OPTIONAL, suppressed == 0 ? "No blocked addresses yet; bounces, complaints and unsubscribes add them." : suppressed + " blocked addresses.", null));
items.add(config.getSchedulerName() == null
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so the outbox is only sent when someone runs Dispatch by hand.", "Set schedulerName in the Email config.")
: new Item("Scheduler", null, State.OK, "Dispatch, webhooks, maintenance and retention run on the " + config.getSchedulerName() + " scheduler.", null));
return (items);
}
/*******************************************************************************
** The value, or the fallback when blank.
*******************************************************************************/
private static String valueOr(String value, String fallback)
{
return (value == null || value.isBlank() ? fallback : value);
}
}
@@ -234,9 +234,16 @@ public final class EmailTableCustomizers
{
for(QRecord row : records)
{
if(!SystemWrites.query(EmailTables.MESSAGE, com.kingsrook.qbits.email.support.EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
//////////////////////////////////////////////////////////////////
// the same history the RESTRICT foreign keys protect (docs/ddl) //
//////////////////////////////////////////////////////////////////
for(String history : List.of(EmailTables.MESSAGE, EmailTables.ATTEMPT, EmailTables.EVENT, EmailTables.INBOX))
{
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
if(!SystemWrites.query(history, EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
{
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
break;
}
}
}
List<QRecord> remaining = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(), input.getTransaction()));
@@ -344,7 +344,8 @@ public final class EmailTables
.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("ownerQbit", QFieldType.STRING).withMaxLength(500).withIsEditable(false)
.withHelpContent(new com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent("Set by the qbit that registered the template. Messages sent from it name that qbit as their sender unless the sender says otherwise.")))
.withField(field("description", QFieldType.TEXT));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
@@ -22,8 +22,10 @@ package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Internal; use EmailApi.
** Durable handoff outcomes. Operational failures propagate to the caller.
*******************************************************************************/
@Deprecated(forRemoval = false)
public enum EmailEffectOutcome
{
QUEUED_OR_DUPLICATE,
@@ -31,17 +31,26 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Internal; use EmailApi.
** Outbox handoff and system-read reconciliation for trusted effect dispatchers.
** This API has no dependency on core durable-job types. Sending retains the
** EmailService permission checks; callers must supply a request without a transaction.
*******************************************************************************/
@Deprecated(forRemoval = false)
public final class EmailEffects
{
/*******************************************************************************
** A refusal can have a reference when suppression retained an audit row.
*******************************************************************************/
public record Result(EmailEffectOutcome outcome, EmailMessageRef message)
public record Result(EmailEffectOutcome outcome, EmailMessageRef message, List<String> reasons)
{
/*******************************************************************************
** Preserve the original constructor for callers that only need the reference.
*******************************************************************************/
public Result(EmailEffectOutcome outcome, EmailMessageRef message)
{
this(outcome, message, List.of());
}
}
@@ -66,14 +75,15 @@ public final class EmailEffects
|| request.getTransaction() != null
|| request.getIdempotencyKey() != null && !request.getIdempotencyKey().equals(effectIdempotencyKey))
{
return (new Result(EmailEffectOutcome.REFUSED, null));
return (new Result(EmailEffectOutcome.REFUSED, null, List.of("An email request without a caller transaction and a valid, matching effect key are required")));
}
request.withIdempotencyKey(effectIdempotencyKey);
EmailSendResult result = EmailService.send(request);
EmailMessageRef message = result.messageId() == null ? null : find("id", result.messageId()).orElseThrow(() -> new QException("Queued email reference is unavailable"));
boolean refused = result.status() == EmailSendResult.Status.REFUSED || result.status() == EmailSendResult.Status.SUPPRESSED
|| message != null && "SUPPRESSED".equals(message.status());
return (new Result(refused ? EmailEffectOutcome.REFUSED : EmailEffectOutcome.QUEUED_OR_DUPLICATE, message));
List<String> reasons = message != null && "SUPPRESSED".equals(message.status()) ? List.of("All recipients are suppressed") : result.reasons();
return (new Result(refused ? EmailEffectOutcome.REFUSED : EmailEffectOutcome.QUEUED_OR_DUPLICATE, message, reasons));
}
@@ -0,0 +1,200 @@
/*
* 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.outbox;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.JobSessions;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectRequest;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectResult;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectTarget;
import com.kingsrook.qqq.backend.core.jobs.effects.ReconcileResult;
import com.kingsrook.qqq.backend.core.jobs.store.DurableJobStore;
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.model.session.QSession;
/*******************************************************************************
** Durable jobs target for IDEMPOTENT_EXTERNAL email effects. SENT acknowledges
** the committed email outbox, whose key deduplication makes retries safe. The
** email dispatcher owns provider delivery and preserves the stored Message-ID.
** Payload keys and types are documented in docs/DURABLE-JOBS.md.
*******************************************************************************/
public class EmailJobEffectTarget implements EffectTarget
{
public static final String TARGET_NAME = "email";
/*******************************************************************************
** Register after enabling durable jobs; leave instances without jobs alone.
*******************************************************************************/
public static void register(QInstance instance)
{
DurableJobsMetaData jobs = DurableJobsMetaData.of(instance);
if(jobs != null)
{
jobs.withEffectTarget(TARGET_NAME, new QCodeReference(EmailJobEffectTarget.class));
}
}
/*******************************************************************************
** Enqueue under the job owner's current permissions and captured scope.
** Other exceptions are uncertain; core may safely resend using the same key.
*******************************************************************************/
@Override
public EffectResult deliver(EffectRequest r)
{
try
{
EmailRequest request = decode(r.payload()).withSender("jobs", r.effectName());
EmailEffects.Result result;
if(r.jobId() == null)
{
result = EmailEffects.send(request, r.idempotencyKey());
}
else
{
QInstance instance = QContext.getQInstance();
QSession owner;
try
{
QRecord job = new DurableJobStore(DurableJobsMetaData.of(instance)).getJob(r.jobId());
if(job == null)
{
return (EffectResult.failed("The job's owner can no longer send it: job not found"));
}
owner = JobSessions.forJob(instance, job);
}
catch(QPermissionDeniedException | QAuthenticationException e)
{
return (EffectResult.failed("The job's owner can no longer send it: owner session unavailable"));
}
AtomicReference<EmailEffects.Result> handoff = new AtomicReference<>();
QContext.withTemporaryContext(new CapturedContext(instance, owner),
() -> handoff.set(EmailEffects.send(request, r.idempotencyKey())));
result = handoff.get();
}
if(result.outcome() == EmailEffectOutcome.REFUSED)
{
return (EffectResult.failed(String.join("; ", result.reasons())));
}
return (EffectResult.sent(String.valueOf(result.message().id())));
}
catch(Exception e)
{
// Exception details can contain payloads or database values.
return (EffectResult.unknown("Email handoff could not be confirmed (" + e.getClass().getSimpleName() + ")"));
}
}
/*******************************************************************************
** Reconcile handoff by existence, including suppressed and terminal audit rows.
*******************************************************************************/
@Override
public ReconcileResult reconcile(EffectRequest r) throws QException
{
return (EmailEffects.findByIdempotencyKey(r.idempotencyKey())
.map(message -> ReconcileResult.found(String.valueOf(message.id())))
.orElseGet(ReconcileResult::notFound));
}
/*******************************************************************************
** Decode only supported values; identity, sender and transaction cannot be
** overridden by payload keys. Variables retain normal template/PHI validation.
*******************************************************************************/
static EmailRequest decode(Map<String, Serializable> payload)
{
Map<String, Serializable> values = payload == null ? Map.of() : payload;
EmailRequest request = new EmailRequest()
.withTo(addresses(values.get("to")))
.withCc(addresses(values.get("cc")))
.withBcc(addresses(values.get("bcc")))
.withFrom((String) values.get("from"))
.withFromAddressId((Integer) values.get("fromAddressId"))
.withReplyTo((String) values.get("replyTo"))
.withSubject((String) values.get("subject"))
.withTemplateName((String) values.get("templateName"))
.withMarkdownBody((String) values.get("markdownBody"))
.withHtmlBody((String) values.get("htmlBody"))
.withTextBody((String) values.get("textBody"))
.withProviderName((String) values.get("providerName"))
.withBrandName((String) values.get("brandName"))
.withLinkedRecord((String) values.get("linkedTableName"), values.get("linkedRecordId"));
if(values.get("locale") != null)
{
request.withLocale((String) values.get("locale"));
}
if(values.get("category") != null)
{
request.withCategory(EmailCategory.valueOf((String) values.get("category")));
}
if(values.get("sendAfter") != null)
{
request.withSendAfter(Instant.parse((String) values.get("sendAfter")));
}
if(values.get("variables") instanceof Map<?, ?> variables)
{
variables.forEach((key, value) -> request.withVariable((String) key, (Serializable) value));
}
else if(values.get("variables") != null)
{
throw (new IllegalArgumentException("variables must be a map"));
}
return (request);
}
/*******************************************************************************
** Accept one mailbox or a list of mailboxes, without comma splitting.
*******************************************************************************/
private static List<String> addresses(Object value)
{
if(value == null)
{
return (List.of());
}
if(value instanceof String address)
{
return (List.of(address));
}
return (((List<?>) value).stream().map(String.class::cast).toList());
}
}
@@ -22,8 +22,10 @@ package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Internal; use EmailApi.
** Reconciliation reference without message bodies, recipients or credentials.
*******************************************************************************/
@Deprecated(forRemoval = false)
public record EmailMessageRef(Integer id, String status, String rfcMessageId, String providerMessageId)
{
}
@@ -270,6 +270,14 @@ public final class EmailService
if(templateContent != null)
{
row.getValues().putAll(templateContent.getValues());
if(row.getValue("senderQbit") == null && row.getValue("templateId") != null)
{
///////////////////////////////////////////////////////////////////////////////
// the qbit that registered the template is the sender when none was named //
///////////////////////////////////////////////////////////////////////////////
List<QRecord> owner = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValue("templateId")), queryTransaction);
row.setValue("senderQbit", owner.isEmpty() ? null : owner.getFirst().getValueString("ownerQbit"));
}
}
for(var field : config.getSecurityFields())
{
@@ -0,0 +1,117 @@
/*
* 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.support;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiStatus;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
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.template.EmailTemplateDefinition;
/*******************************************************************************
** Internal adapter between the stable contract and the implementation.
*******************************************************************************/
public final class EmailApiMapper
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailApiMapper()
{
}
/*******************************************************************************
** Map every published request field without bypassing send validation.
*******************************************************************************/
public static EmailRequest request(EmailApiRequest request)
{
if(request == null)
{
return (null);
}
EmailRequest mapped = new EmailRequest()
.withTo(request.getTo())
.withCc(request.getCc())
.withBcc(request.getBcc())
.withFrom(request.getFrom())
.withFromAddressId(request.getFromAddressId())
.withReplyTo(request.getReplyTo())
.withSubject(request.getSubject())
.withMarkdownBody(request.getMarkdownBody())
.withHtmlBody(request.getHtmlBody())
.withTextBody(request.getTextBody())
.withProviderName(request.getProviderName())
.withTemplateName(request.getTemplateName())
.withBrandName(request.getBrandName())
.withLocale(request.getLocale())
.withIdempotencyKey(request.getIdempotencyKey())
.withTransaction(request.getTransaction(), request.getTransactionBackendName())
.withSendAfter(request.getSendAfter())
.withLinkedTableName(request.getLinkedTableName())
.withLinkedRecordId(request.getLinkedRecordId())
.withSenderQbit(request.getSenderQbit())
.withSenderProcess(request.getSenderProcess())
.withVariables(request.getVariables())
.withCategory(request.getCategory() == null ? null : EmailCategory.valueOf(request.getCategory().name()));
request.getRecordVariables().forEach((name, reference) -> mapped.withRecordVariable(name, reference.table(), reference.id()));
return (mapped);
}
/*******************************************************************************
** Map the complete create-once template contract.
*******************************************************************************/
public static EmailTemplateDefinition template(EmailApiTemplate template)
{
EmailTemplateDefinition mapped = new EmailTemplateDefinition()
.withName(template.getName())
.withLabel(template.getLabel())
.withKind(template.getKind())
.withSubject(template.getSubject())
.withBody(template.getBody())
.withTextBody(template.getTextBody())
.withVariablesSchema(template.getVariablesSchema())
.withSampleVariables(template.getSampleVariables())
.withOwnerQbit(template.getOwnerQbit())
.withEditPermissionName(template.getEditPermissionName())
.withCategory(template.getCategory() == null ? null : EmailCategory.valueOf(template.getCategory().name()));
template.getVariables().forEach(variable -> mapped.withVariable(variable.name(), variable.label(), variable.type(), variable.required(), variable.classification()));
return (mapped);
}
/*******************************************************************************
** Map enqueue outcomes by their stable names.
*******************************************************************************/
public static EmailApiResult result(EmailSendResult result)
{
return (new EmailApiResult(result.messageId(), EmailApiStatus.valueOf(result.status().name()), result.reasons()));
}
}
@@ -36,6 +36,8 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
*******************************************************************************/
public final class EmailBuiltIns
{
public static final String OWNER = "qbit-email";
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -55,7 +57,7 @@ public final class EmailBuiltIns
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();
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false).withValue("ownerQbit", OWNER)), tx).getRecords().getFirst();
if(template.getValue("id") == null || template.getErrors() != null && !template.getErrors().isEmpty())
{
tx.rollback();
@@ -0,0 +1,32 @@
-- liquibase formatted sql
-- qbit-email starter defaults: one neutral, high-contrast brand to copy or select,
-- so the Brands table is not empty and its colors are a safe starting point.
-- Include it from the host's master changelog AFTER the changesets that create the
-- email_* tables (docs/ddl/email-postgres.sql):
--
-- - include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
--
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
-- Rules for this file:
-- * insert-only, and only when the host has no brand at all, so a host that already
-- configured its branding is never touched;
-- * it never changes how mail looks on deploy: the brand is NOT the default brand
-- (is_default = FALSE), so it applies only when someone selects it (a template's brand,
-- a request's brand name) or ticks Default Brand on it;
-- * no secrets, and portable SQL (PostgreSQL and H2);
-- * every changeset has a precondition (sql-check only; table-exists is not portable across
-- Liquibase versions), so a host without the table skips it (MARK_RAN).
-- Providers, sender addresses, suppression and every runtime table are NOT seeded: a provider
-- is the host's own connection (a server and the NAMES of its secret variables). The built-in
-- secure-message template is created by the qbit itself on first use and when the Email home opens.
-- EmailDefaultsChangelogTest runs this file through Liquibase on a fresh schema.
-- changeset qbit-email:defaults-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand
-- comment: A neutral brand (white background, near-black text, blue buttons; light and dark schemes, every pair at least 4.5:1 contrast). Not the default brand, so nothing changes until it is selected.
INSERT INTO email_brand (name, label, primary_color, text_color, background_color, button_text_color, dark_primary_color, dark_text_color, dark_background_color, dark_button_text_color, is_default, create_date, modify_date)
SELECT 'neutral', 'Neutral', '#2459a6', '#171717', '#ffffff', '#ffffff', '#8ab4f8', '#f5f5f5', '#121212', '#000000', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM email_brand);
-- rollback empty
+708
View File
@@ -0,0 +1,708 @@
bundle: qbit-email
version: 0.1.0-SNAPSHOT
layer: QBIT
entries:
- key: app:email;slot:help
summary: 'Manage outgoing email, templates, delivery results and addresses that must not receive messages.'
content: |
Use **Messages** to follow outgoing mail from queuing through delivery attempts and provider events. Open **Templates** and their versions to prepare reusable content, and **Brands** to control its appearance.
Configure delivery services and sender addresses under **Providers**. **Suppressions** records addresses that should not receive some or all mail. Sending uses the outbox, so a queued message is not yet proof of delivery. Your email permissions determine which settings and actions you can use.
- key: table:email_provider;slot:help
summary: 'A delivery service and its sender, connection, tracking and rate-limit settings.'
content: |
**How rows get created:** Add your delivery connection with **New**, then configure an enabled default before sending. Without a provider, messages cannot be sent.
Configure a provider before sending mail. Choose the delivery service, connection settings and sender identity, then use **Send Test Email** to check it. Credential settings hold environment variable names; the server resolves their values when needed.
When providers are enabled, exactly one must be the enabled default. Providers with message history cannot be deleted; disable them instead. Open **Sender Addresses** to manage additional senders, **Messages** to inspect outgoing mail and **Webhook Inbox** to inspect delivery notifications.
- key: table:email_provider;field:name
summary: 'A unique name used to select this delivery provider.'
- key: table:email_provider;field:label
summary: 'The display name for this provider.'
- key: table:email_provider;field:kind
summary: 'The delivery service or protocol used to send mail.'
content: |
Choose the transport used by your delivery service. The available choices are {{values:email_provider_kind}}. Connection and credential requirements depend on the chosen kind, so check the provider setup before testing. Gmail API sending is not implemented in this version; use Gmail SMTP when appropriate. Use **Send Test Email** to check the configured provider.
- key: table:email_provider;field:enabled
summary: 'Whether this provider is available for sending mail.'
- key: table:email_provider;field:isDefault
summary: 'Whether this is the enabled provider used when no provider is selected.'
content: |
The default provider is selected when a send does not name a provider. When any providers are enabled, exactly one must be the enabled default. Coordinate changes to the old and new defaults together. This choice selects the delivery service; the default under **Sender Addresses** separately controls the preferred sender within that service.
- key: table:email_provider;field:fromAddress
summary: 'The fallback sender address when no sender-address row takes precedence.'
content: |
These values provide the provider’s fallback sending identity. An enabled default under **Sender Addresses** takes precedence. Once sender-address rows exist, an explicitly supplied sender must match an allowed address for this provider. Check both the fallback and the sender rows when a send is refused or a test uses an unexpected identity.
- key: table:email_provider;field:fromName
summary: 'The display name accompanying the fallback sender address.'
content: |
These values provide the provider’s fallback sending identity. An enabled default under **Sender Addresses** takes precedence. Once sender-address rows exist, an explicitly supplied sender must match an allowed address for this provider. Check both the fallback and the sender rows when a send is refused or a test uses an unexpected identity.
- key: table:email_provider;field:replyTo
summary: 'The address recipients should use when replying.'
- key: table:email_provider;field:sendingDomain
summary: 'The sending domain used by the provider and its allowed sender addresses.'
content: |
The sending domain is used by provider-specific delivery configuration and constrains **Sender Addresses**. When a domain is configured, every sender-address row must belong to it. A change that would leave existing sender rows in another domain is refused. Review the provider and its sender list together before changing this setting, then test the resulting delivery configuration.
- key: table:email_provider;field:host
summary: 'The mail server hostname for an SMTP connection.'
- key: table:email_provider;field:port
summary: 'The mail server port for an SMTP connection.'
- key: table:email_provider;field:security
summary: 'The transport security setting for the SMTP connection.'
content: |
Choose the connection protection required by your SMTP server. The available settings are {{values:email_security}}, with metadata default {{default:email_provider.security}}. STARTTLS requires upgrading the connection, while TLS starts with an encrypted connection. Match the host and port to the server’s configuration. Check the result with **Send Test Email** before relying on outgoing mail.
- key: table:email_provider;field:region
summary: 'The provider region used for delivery.'
- key: table:email_provider;field:usernameEnvName
summary: 'The environment variable name containing the provider’s login name or access key.'
content: |
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
- key: table:email_provider;field:secretEnvName
summary: 'The environment variable name containing the provider’s password or API credential.'
content: |
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
- key: table:email_provider;field:webhookSecretEnvName
summary: 'The environment variable name containing the notification verification credential.'
content: |
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
- key: table:email_provider;field:snsTopicArn
summary: 'The Amazon notification topic expected for SES delivery notifications.'
- key: table:email_provider;field:configurationSet
summary: 'The SES configuration set attached to outgoing mail.'
- key: table:email_provider;field:messageStream
summary: 'The Postmark message stream used for outgoing mail.'
- key: table:email_provider;field:maxPerMinute
summary: 'The provider’s configured sending limit per minute.'
- key: table:email_provider;field:maxPerDay
summary: 'The provider’s configured sending limit per day.'
- key: table:email_provider;field:trackOpens
summary: 'Whether open tracking is requested from a provider that supports it.'
content: |
Tracking requests engagement information from a provider that supports it. These settings are separate from basic sending and delivery notifications. Review whether tracking is appropriate for your messages before enabling it. Reported engagement appears under **Events** on a message; absence of a tracking event does not prove the recipient did not read or use the message.
- key: table:email_provider;field:trackClicks
summary: 'Whether link tracking is requested from a provider that supports it.'
content: |
Tracking requests engagement information from a provider that supports it. These settings are separate from basic sending and delivery notifications. Review whether tracking is appropriate for your messages before enabling it. Reported engagement appears under **Events** on a message; absence of a tracking event does not prove the recipient did not read or use the message.
- key: table:email_provider;field:phiAllowed
summary: 'Whether this provider is approved to receive protected health information.'
content: |
This setting records whether the provider may receive protected health information. It is only one part of the send checks: template-version approval and variable classification also matter. Review your approved provider arrangement before enabling it. If a protected message is refused, inspect the **Template**, its approved version and the chosen provider rather than treating this flag as a universal override.
- key: table:email_provider;field:healthStatus
summary: 'The provider’s current sending health state.'
content: |
The recorded health states are {{values:email_health_status}}. Use this together with the most recent success, failure time and failure message to investigate provider problems. Account and credential failures can pause sending through a provider. Check the configuration and use **Send Test Email** after correcting the cause, then inspect the resulting message and attempts.
- key: table:email_provider;field:lastSuccessDate
summary: 'When sending through this provider last succeeded.'
- key: table:email_provider;field:lastFailureDate
summary: 'When sending through this provider last failed.'
- key: table:email_provider;field:lastFailureMessage
summary: 'The most recent sending failure recorded for this provider.'
- key: table:email_provider_sender_address;slot:help
summary: 'An address you can send from through a particular email provider.'
content: |
**How rows get created:** Add an address from a provider's **Sender Addresses** list. Without sender-address rows, the provider's fallback sender is used.
Use **Sender Addresses** on a provider to manage its allowed sending identities. Each address must be unique within that provider, and an address must match the provider’s sending domain when one is configured.
At most one enabled address can be the default. That default takes precedence over the provider’s fallback sender. Once sender rows exist, an explicitly chosen sender must match an enabled row for the selected provider. The selected address is recorded on **Messages**.
- key: table:email_provider_sender_address;field:providerId
summary: 'The provider allowed to use this sender address.'
- key: table:email_provider_sender_address;field:address
summary: 'The email address used as the sender.'
- key: table:email_provider_sender_address;field:displayName
summary: 'The name recipients see beside the sender address.'
- key: table:email_provider_sender_address;field:isDefault
summary: 'Whether this enabled address is the provider’s preferred sender.'
- key: table:email_provider_sender_address;field:enabled
summary: 'Whether this address can be selected for sending.'
- key: table:email_provider_sender_address;field:note
summary: 'Your notes about this sender address.'
- key: table:email_message;slot:help
summary: 'An outgoing email with its saved content, delivery state and retry history.'
content: |
**How rows get created:** Your application queues messages through the email service. You do not enter them manually. An empty list means no messages are currently retained.
Use **Messages** to check whether mail is waiting, being attempted, sent or reported as delivered. A provider accepting a message does not by itself prove that the recipient received it. Open **Attempts** for individual sending attempts and **Events** for provider notifications.
Messages are written by the email service, rather than edited directly. Use **Cancel**, **Send Now** or **Resend** when the message’s state permits the action. Saved content reflects the original send and may later be cleared by retention.
- key: table:email_message;field:status
summary: 'The current delivery state of the outgoing message.'
content: |
The available states are {{values:email_message_status}}. Use the state to distinguish waiting or retrying mail from a provider handoff and later delivery events. A sent message is not necessarily delivered. Open **Attempts** for sending errors and **Events** for delivery, bounce or complaint reports before deciding whether a resend is appropriate.
- key: table:email_message;field:toAddresses
summary: 'The message’s primary recipients.'
- key: table:email_message;field:ccAddresses
summary: 'The recipients copied visibly on the message.'
- key: table:email_message;field:bccAddresses
summary: 'The recipients copied without appearing in the message’s recipient headers.'
- key: table:email_message;field:fromAddress
summary: 'The sender address saved for this message.'
- key: table:email_message;field:fromAddressId
summary: 'The provider sender-address record selected for this message.'
- key: table:email_message;field:replyTo
summary: 'The reply address saved for this message.'
- key: table:email_message;field:subject
summary: 'The subject saved when the message was prepared.'
- key: table:email_message;field:templateId
summary: 'The template used to prepare this message.'
- key: table:email_message;field:templateVersionId
summary: 'The exact template version used to prepare this message.'
- key: table:email_message;field:brandId
summary: 'The brand used when rendering this message.'
- key: table:email_message;field:category
summary: 'The message category used for delivery and suppression rules.'
content: |
The categories are {{values:email_category}}. Category affects how suppression and unsubscribe rules apply, so check it when a recipient is omitted from a message. It also matters for retention: notification and marketing records remain to keep unsubscribe links working even after old content is cleared. Review the related **Suppression** and **Template** when investigating.
- key: table:email_message;field:locale
summary: 'The language or locale requested for the message.'
- key: table:email_message;field:variablesSnapshot
summary: 'The retained template variables, with sensitive values redacted.'
content: |
Use the saved variables to investigate what was queued. Sensitive values are replaced by non-recoverable tokens or redaction markers, so this snapshot is not a copy of every original input. Later template changes do not regenerate it. Retention can clear saved variables while preserving the message record and its identifiers.
- key: table:email_message;field:htmlBody
summary: 'The rendered HTML content saved for this message.'
content: |
The message keeps the content prepared for this particular send. Inspect it when you need to understand what was queued, rather than assuming the current template still matches the original. Later template changes do not regenerate this saved content. Retention can clear bodies and variable values while preserving the message record and its identifiers.
- key: table:email_message;field:textBody
summary: 'The plain-text content saved for this message.'
content: |
The message keeps the content prepared for this particular send. Inspect it when you need to understand what was queued, rather than assuming the current template still matches the original. Later template changes do not regenerate this saved content. Retention can clear bodies and variable values while preserving the message record and its identifiers.
- key: table:email_message;field:phiApproved
summary: 'Whether the message carries approval for protected health information.'
- key: table:email_message;field:unsubscribeUrl
summary: 'The unsubscribe link prepared for this message.'
- key: table:email_message;field:bodySha256
summary: 'A checksum identifying the saved message content.'
- key: table:email_message;field:idempotencyKey
summary: 'A caller-supplied reference used to prevent duplicate message creation.'
content: |
A sending caller can provide a stable reference so repeating the same request does not create duplicate mail while that record remains stored. Use it to trace retried application requests alongside the recorded message identifier. The reference is not the provider’s message identifier and does not prove delivery. Retention settings can affect how long message records remain available.
- key: table:email_message;field:providerId
summary: 'The delivery provider chosen for this message.'
- key: table:email_message;field:providerMessageId
summary: 'The identifier returned by the delivery provider.'
- key: table:email_message;field:attemptCount
summary: 'The number of sending attempts recorded for this message.'
- key: table:email_message;field:nextAttemptDate
summary: 'When this message next becomes eligible for a sending attempt.'
content: |
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
- key: table:email_message;field:leaseExpiresDate
summary: 'When the dispatcher’s current claim on this message expires.'
content: |
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
- key: table:email_message;field:lastError
summary: 'The latest error recorded while sending this message.'
- key: table:email_message;field:finishedDate
summary: 'When the message reached its recorded terminal state.'
- key: table:email_message;field:sentDate
summary: 'When the message was successfully handed to its provider.'
- key: table:email_message;field:deliveredDate
summary: 'When delivery was recorded from the provider’s notification.'
- key: table:email_message;field:bouncedDate
summary: 'When a bounce was recorded for this message.'
- key: table:email_message;field:complainedDate
summary: 'When a recipient complaint was recorded for this message.'
- key: table:email_message;field:deadLetterDate
summary: 'When the message was moved out of automatic retrying.'
- key: table:email_message;field:linkedTableName
summary: 'The type of application record associated with this message.'
- key: table:email_message;field:linkedRecordId
summary: 'The application record associated with this message.'
- key: table:email_message;field:senderQbit
summary: 'The application component that requested this message.'
- key: table:email_message;field:senderProcess
summary: 'The action that requested this message.'
- key: table:email_message;field:suppressedAddresses
summary: 'Recipients omitted because a suppression applied.'
- key: table:email_message;field:sendAfter
summary: 'The earliest requested time for sending this message.'
content: |
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
- key: table:email_message_attempt;slot:help
summary: 'One attempt to hand an outgoing message to its delivery provider.'
content: |
**How rows get created:** The dispatcher records each attempt to send a message. An empty list means no attempts are currently retained.
Open **Attempts** from a message when you need to understand a retry or sending failure. Each row records the provider, attempt number, timing and outcome of one attempt. The worker and lease information help distinguish active work from an abandoned claim.
These records are written by the dispatcher and are not editable. Compare the error and outcome with the parent **Message**, which holds the overall delivery state and next retry time.
- key: table:email_message_attempt;field:messageId
summary: 'The outgoing message this attempt belongs to.'
- key: table:email_message_attempt;field:attemptNumber
summary: 'The sequence number of this sending attempt.'
- key: table:email_message_attempt;field:outcome
summary: 'The result of this individual sending attempt.'
content: |
The possible outcomes are {{values:email_attempt_outcome}}. An attempt records the result of one handoff to a provider, while **Message** holds the overall state and retry timing. Review the error and the other attempts to distinguish a temporary problem from a repeated provider failure. Even a successful handoff can later receive a bounce event.
- key: table:email_message_attempt;field:providerId
summary: 'The provider used for this attempt.'
- key: table:email_message_attempt;field:claimedBy
summary: 'The dispatcher worker that claimed this attempt.'
- key: table:email_message_attempt;field:startDate
summary: 'When this sending attempt started.'
- key: table:email_message_attempt;field:endDate
summary: 'When this sending attempt ended.'
- key: table:email_message_attempt;field:leaseExpiresDate
summary: 'When this worker’s claim on the attempt expires.'
- key: table:email_message_attempt;field:durationMillis
summary: 'The elapsed time for this attempt in milliseconds.'
- key: table:email_message_attempt;field:error
summary: 'The error recorded for this attempt.'
- key: table:email_template;slot:help
summary: 'Reusable email content with versions, branding and approval information.'
content: |
**How rows get created:** Add a template with **New**, or use templates registered by your application. The built-in secure-message template is created on first use or when you open the Email home.
Use **Templates** for messages you send repeatedly with different recipient data. A template identifies its format, category and brand; its **Versions** hold the actual subject and content. **New Version** creates a draft you can edit without changing the currently active version.
Preview the draft with sample variables before choosing **Activate Version**. Approval for protected health information is separate from activation and is tied to a particular version. Template editing can require an additional permission.
- key: table:email_template;field:name
summary: 'A unique name used to select this template.'
- key: table:email_template;field:label
summary: 'The display name of the template.'
- key: table:email_template;field:category
summary: 'The category applied to messages prepared from this template.'
- key: table:email_template;field:kind
summary: 'The source format used to write this template’s versions.'
- key: table:email_template;field:brandId
summary: 'The brand used to style this template.'
- key: table:email_template;field:activeVersionId
summary: 'The version currently selected for normal sends.'
- key: table:email_template;field:approvedVersionId
summary: 'The specific version approved for protected health information.'
content: |
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
- key: table:email_template;field:phiApproved
summary: 'Whether this template has recorded approval for protected health information.'
content: |
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
- key: table:email_template;field:phiApprovedBy
summary: 'Who approved this template for protected health information.'
content: |
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
- key: table:email_template;field:phiApprovedDate
summary: 'When approval for protected health information was recorded.'
content: |
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
- key: table:email_template;field:editPermissionName
summary: 'An additional permission required to edit this template.'
- key: table:email_template;field:ownerQbit
summary: 'The application component responsible for this template.'
- key: table:email_template;field:description
summary: 'Your description of when to use this template.'
- key: table:email_template_version;slot:help
summary: 'A draft or published revision of a template’s subject, content and variables.'
content: |
**How rows get created:** Use **New Version** on a template to create a draft. Application-registered templates can also create their initial version.
Open a template’s **Versions** to edit a draft or inspect the content used by an earlier message. The **Preview** shows rendered content using sample variables. Provide the source in the format chosen on the parent **Template**, with optional plain-text content and a variable schema.
Only unapproved drafts can be edited. Use **Activate Version** to make reviewed content available for normal sends. Existing messages retain their saved content; activating a version does not rewrite them.
- key: table:email_template_version;field:templateId
summary: 'The template this version belongs to.'
- key: table:email_template_version;field:versionNumber
summary: 'The revision number within the template.'
- key: table:email_template_version;field:status
summary: 'Whether this version is a draft, active or retired.'
- key: table:email_template_version;field:locale
summary: 'The language or locale of this version.'
- key: table:email_template_version;field:subject
summary: 'The subject template rendered with message variables.'
- key: table:email_template_version;field:preheader
summary: 'The short preview text shown by supporting email clients.'
- key: table:email_template_version;field:bodySource
summary: 'The editable source for the message’s main content.'
content: |
Write the main content in the format chosen on the parent **Template**. Edit a draft, then use **Preview** with sample variables to inspect the result. Generated HTML is recorded separately in **{{label:email_template_version.compiledHtml}}**. Activate the reviewed version when it is ready for normal sending; editing source does not change messages that have already been prepared.
- key: table:email_template_version;field:textSource
summary: 'The editable source for the plain-text alternative.'
- key: table:email_template_version;field:compiledHtml
summary: 'The generated HTML produced from the version’s source.'
- key: table:email_template_version;field:variablesSchema
summary: 'The declared variables and their classifications for this version.'
content: |
Declare the variables expected by the version and provide representative example values for **Preview**. The schema also carries classifications used by protected-data checks. Use sample data that is appropriate for previewing and review the rendered subject and body before activation. The sample values are for checking the template; actual sends supply their own values and save a message snapshot.
- key: table:email_template_version;field:sampleVariables
summary: 'Example variable values used to preview this version.'
content: |
Declare the variables expected by the version and provide representative example values for **Preview**. The schema also carries classifications used by protected-data checks. Use sample data that is appropriate for previewing and review the rendered subject and body before activation. The sample values are for checking the template; actual sends supply their own values and save a message snapshot.
- key: table:email_template_version;field:createdBy
summary: 'Who created this template version.'
- key: table:email_template_version;field:activatedDate
summary: 'When this version was activated.'
- key: table:email_brand;slot:help
summary: 'Shared logos, colors and footer information for rendered email templates.'
content: |
**How rows get created:** Add a brand with **New**. If your host installs the optional defaults changelog, you can copy its neutral starter brand and customize it.
Use **Brands** to give related templates a consistent appearance. Set the logos, light and dark colors, site address and footer information that your messages need. Associate the brand with a **Template** to use it when rendering email.
Check the template **Preview** after changing branding, including the dark appearance and text contrast. Branding supplies presentation and footer data; it does not configure the email provider or verify a sender address.
- key: table:email_brand;field:name
summary: 'A unique name used to select this brand.'
- key: table:email_brand;field:label
summary: 'The display name of this brand.'
- key: table:email_brand;field:logoUrl
summary: 'The logo image address used in the standard appearance.'
- key: table:email_brand;field:logoDarkUrl
summary: 'The logo image address used in the dark appearance.'
- key: table:email_brand;field:logoAlt
summary: 'The text alternative describing the logo.'
- key: table:email_brand;field:primaryColor
summary: 'The main accent color used in branded email.'
- key: table:email_brand;field:textColor
summary: 'The text color used in the standard appearance.'
- key: table:email_brand;field:backgroundColor
summary: 'The background color used in the standard appearance.'
- key: table:email_brand;field:darkBackgroundColor
summary: 'The background color used in the dark appearance.'
- key: table:email_brand;field:darkPrimaryColor
summary: 'The accent color used in the dark appearance.'
- key: table:email_brand;field:darkButtonTextColor
summary: 'The button text color used in the dark appearance.'
- key: table:email_brand;field:darkTextColor
summary: 'The text color used in the dark appearance.'
- key: table:email_brand;field:buttonTextColor
summary: 'The button text color used in the standard appearance.'
- key: table:email_brand;field:footerMarkdown
summary: 'The shared email footer written in Markdown.'
- key: table:email_brand;field:postalAddress
summary: 'The postal address available to the branded footer.'
- key: table:email_brand;field:socialLinks
summary: 'The social links supplied to branded email rendering.'
- key: table:email_brand;field:siteUrl
summary: 'The website address associated with this brand.'
- key: table:email_brand;field:isDefault
summary: 'Whether this brand is marked as the default.'
- key: table:email_brand;field:appName
summary: 'The application name displayed in branded email.'
- key: table:email_suppression;slot:help
summary: 'An address blocked from some or all email because of a delivery or preference event.'
content: |
**How rows get created:** Provider bounces, complaints and unsubscribe requests can create restrictions automatically. You can also add a manual block with **New**.
Check **Suppressions** when a recipient is omitted from a send. Each entry identifies an address, the scope of the restriction and its reason, such as a bounce, complaint or unsubscribe. The source message and event help explain why it was added.
Review the reason before changing a suppression. Complaint entries require email administrator permission to change or remove. An expiry limits how long an entry applies; removing a restriction does not resend previously suppressed messages.
- key: table:email_suppression;field:address
summary: 'The email address affected by this suppression.'
- key: table:email_suppression;field:scope
summary: 'The categories of mail blocked for this address.'
content: |
Scope determines which categories of mail a suppression blocks. The configured choices are {{values:email_suppression_scope}}. Review the reason and source event before changing a restriction, especially for complaints and unsubscribe requests. A suppression can omit a recipient from a send without changing the template or provider. Removing it does not automatically resend previously omitted mail.
- key: table:email_suppression;field:reason
summary: 'Why this address was suppressed.'
- key: table:email_suppression;field:sourceMessageId
summary: 'The message that led to this suppression.'
- key: table:email_suppression;field:sourceEventId
summary: 'The provider event that led to this suppression.'
- key: table:email_suppression;field:note
summary: 'Notes explaining this suppression.'
- key: table:email_suppression;field:createdDate
summary: 'When the suppression was created.'
- key: table:email_suppression;field:expiresDate
summary: 'When this suppression stops applying, if an expiry was set.'
- key: table:email_webhook_inbox;slot:help
summary: 'A provider notification received for later processing into email delivery events.'
content: |
**How rows get created:** Verified provider callbacks create inbox rows for background processing. Configure your provider's notification destination to receive delivery updates.
Use **Webhook Inbox** on a provider to investigate delivery notifications. A row keeps the received payload, its external identifier, processing state and any retry error. Processing can create **Events** and update the related **Message** or **Suppression**.
These rows are service-written. If processing failed, investigate the error before using **Reprocess**. The inbox tracks notification processing separately from sending attempts, so a failed inbox row does not necessarily mean the original email failed to send.
- key: table:email_webhook_inbox;field:providerId
summary: 'The provider that sent this notification.'
- key: table:email_webhook_inbox;field:kind
summary: 'The provider notification format recorded for this payload.'
- key: table:email_webhook_inbox;field:externalEventId
summary: 'The provider’s event identifier used to recognize repeat notifications.'
- key: table:email_webhook_inbox;field:nonce
summary: 'A notification reference used to detect replayed requests.'
content: |
The notification reference helps the receiver recognize replayed requests. It is recorded alongside the provider and external event identifier so repeated notifications can be handled safely. Use the inbox status, payload checksum and error when investigating duplicate or failed notifications. This value identifies notification processing; it is not a sender credential or proof of email delivery.
- key: table:email_webhook_inbox;field:status
summary: 'The notification’s current processing state.'
- key: table:email_webhook_inbox;field:attempts
summary: 'The number of processing attempts recorded for this notification.'
- key: table:email_webhook_inbox;field:nextAttemptDate
summary: 'When this notification is next eligible for processing.'
- key: table:email_webhook_inbox;field:lastError
summary: 'The latest error encountered while processing the notification.'
- key: table:email_webhook_inbox;field:payloadJson
summary: 'The received notification content stored for processing.'
- key: table:email_webhook_inbox;field:payloadSha256
summary: 'A checksum of the received notification content.'
- key: table:email_webhook_inbox;field:receivedDate
summary: 'When the notification was received.'
- key: table:email_webhook_inbox;field:processedDate
summary: 'When processing of the notification completed.'
- key: table:email_event;slot:help
summary: 'A delivery, bounce, complaint or engagement event reported for an outgoing message.'
content: |
**How rows get created:** Notification processing records events reported by your provider. You do not enter them manually; an empty list can mean no callbacks have arrived.
Open **Events** from a message to see what its provider reported after sending. Events can record delivery, bounces, complaints and engagement, together with the recipient, occurrence time and available detail. Availability depends on the provider and tracking configuration.
Events are written by notification processing, not entered manually. Compare them with **Attempts** when diagnosing mail: attempts describe handing mail to a provider, while events describe what the provider reported afterward.
- key: table:email_event;field:messageId
summary: 'The outgoing message this event describes.'
- key: table:email_event;field:providerId
summary: 'The provider that reported this event.'
- key: table:email_event;field:eventType
summary: 'The kind of delivery or engagement event reported.'
- key: table:email_event;field:bounceType
summary: 'The provider’s classification of the bounce.'
- key: table:email_event;field:recipient
summary: 'The recipient affected by this event.'
- key: table:email_event;field:occurredDate
summary: 'When the event occurred according to the recorded notification.'
- key: table:email_event;field:detail
summary: 'Additional information recorded for the event.'
- key: table:email_event;field:url
summary: 'The link associated with this event, when reported.'
- key: process:email_resend;slot:help
summary: 'Queue another message using an eligible message’s saved content.'
content: |
Use **Resend** after reviewing a failed or returned message and correcting its underlying problem. The action creates another send through the outbox using the saved message information; it does not rewrite the original delivery history. Review the selected messages first. Suppression, provider and content checks still apply, so resending is not a guarantee that mail will be accepted or delivered.
- key: process:email_cancel;slot:help
summary: 'Cancel an eligible outgoing message before the dispatcher sends it.'
content: |
Use **Cancel** when a queued message should no longer be sent. Review the selected messages before continuing. The action checks their current state again and cancels only messages still eligible for cancellation. It cannot recall mail already handed to a provider. Check **Messages** and **Attempts** afterward if a selected message was skipped or had already been claimed.
- key: process:email_send_now;slot:help
summary: 'Attempt to send an eligible queued message without waiting for normal dispatch.'
content: |
Use **Send Now** when you want the dispatcher to attempt an eligible message immediately. Review the selected messages before continuing. The send still goes through the outbox dispatcher and its provider, suppression and delivery controls. Open **Attempts** afterward to inspect the result. A successful handoff to a provider is separate from a later delivery notification.
- key: process:email_send_test;slot:help
summary: 'Send a test email through the selected enabled provider.'
content: |
Use **Send Test Email** to check a provider’s configuration with a recipient you control. Enter **To address** and optionally select one of that provider’s sender addresses. The test is queued through the normal email service and then attempted immediately. Review the result and the resulting **Message** or **Attempts** if the provider refuses the send or delivery does not arrive.
- key: process:email_send_templated;slot:help
summary: 'Prepare and queue an email using a template and supplied variables.'
content: |
Use **Send Email** when an application needs to send reusable template content. The service resolves the template version, variables, sender and delivery provider, then records the prepared message in **Messages**. Queuing and delivery are separate steps. Inspect the returned result and message state rather than assuming that accepting the request means the recipient has received the email.
- key: process:email_send_ad_hoc;slot:help
summary: 'Prepare and queue an email from supplied subject and body content.'
content: |
Use **Send Ad Hoc Email** for content supplied directly instead of through a reusable template. The request still passes through the email service and durable outbox, including recipient, provider and suppression checks. Inspect the result in **Messages** and its **Attempts**. A queued result means the request was stored for sending, not that delivery has already succeeded.
- key: process:email_new_version;slot:help
summary: 'Create a new editable draft version of the selected email template.'
content: |
Use **New Version** when you need to change a template without editing the active version. Review the selected template, then continue to create a draft. Open its **Versions** to edit the content and sample variables. Check **Preview** before using **Activate Version**. Creating a draft alone does not select it for normal outgoing messages or approve it for protected health information.
- key: process:email_activate_version;slot:help
summary: 'Make a reviewed draft the active version of its email template.'
content: |
Use **Activate Version** after checking the draft’s subject, body and sample preview. Review the selected version before continuing. Activation changes the version used for subsequent template sends; it does not regenerate messages already in the outbox. Approval for protected health information is a separate decision. Open the parent **Template** afterward to check its active and approved version references.
- key: process:email_approve_phi;slot:help
summary: 'Record approval of a template version for protected health information.'
content: |
Use **Approve For PHI** only after reviewing the selected version and its variable declarations for the intended protected health information. This is an email administrator action and records approval for a specific version. Approval does not make every provider suitable for sensitive delivery. Check the parent **Template** and the chosen **Provider** when investigating why a protected message was refused.
- key: process:email_reprocess_webhook;slot:help
summary: 'Return a failed provider notification to the processing queue.'
content: |
Use **Reprocess** from **Webhook Inbox** after investigating a failed notification. Review the selected records and correct the cause before retrying. Eligible failed rows return to pending processing; the inbox processor later turns them into delivery events and related updates. This action retries notification handling, not the original email send. Check the inbox status and error after processing runs again.
- key: process:email_process_webhooks;slot:help
summary: 'Process due provider notifications into email events and delivery updates.'
content: |
This background action processes notifications waiting in **Webhook Inbox**. Use its results when investigating why a provider’s notification has not appeared under a message’s **Events**. Processing can update delivery state and suppressions as appropriate. Failed notifications retain diagnostic and retry information. Review that information before using **Reprocess** on a failed inbox record.
- key: process:email_dispatch;slot:help
summary: 'Attempt delivery of outgoing messages that are due for sending.'
content: |
The dispatcher claims eligible messages from the outbox and attempts delivery through their providers. Use **Messages** and **Attempts** to inspect what happened, including provider errors and scheduled retries. Dispatch honors the service’s delivery controls and records each attempt. A completed send attempt can still be followed by a delivery, bounce or complaint event reported by the provider.
- key: process:email_maintain;slot:help
summary: 'Maintain the outgoing email queue and recover expired delivery claims.'
content: |
This background action performs the dispatcher’s queue maintenance. Use it when investigating messages whose worker claim expired or whose delivery state needs maintenance before another attempt. Review **Messages** and **Attempts** for the resulting state and error details. Maintenance is separate from processing provider notifications, which are handled through **Webhook Inbox**, and from clearing old retained message content.
- key: process:email_retention;slot:help
summary: 'Clear expired email content and remove old delivery detail according to retention settings.'
content: |
Retention limits how long detailed email content remains stored. This action clears old bodies and saved variable values. Notification and marketing message records remain so their unsubscribe links keep working; other completed messages and their attempts and events can be deleted when message retention expires. Use **Messages** to inspect retained history. Missing old content can reflect retention rather than a sending failure.
- key: widget:email_provider_children;slot:label
summary: 'Messages sent through this provider.'
- key: widget:email_message_children;slot:label
summary: 'Sending attempts for this message.'
- key: widget:email_template_children;slot:label
summary: 'Versions of this email template.'
- key: widget:email_webhook_inbox_children;slot:label
summary: 'Notifications received from this provider.'
- key: widget:email_provider_sender_address_children;slot:label
summary: 'Sender addresses configured for this provider.'
- key: widget:email_event_children;slot:label
summary: 'Delivery and engagement events for this message.'
- key: widget:emailHome;slot:label
summary: 'Email delivery totals, provider activity and recent messages visible to you.'
- key: widget:emailTemplatePreview;slot:label
summary: 'A rendered preview of this email template version using sample variables.'
- key: table:email_message;field:rfcMessageId
summary: The stable Message-ID used in the outgoing email headers.
content: |
Generated when the message is queued and reused on delivery attempts. This identifies the message independently of **{{label:email_message.providerMessageId}}**.
- key: process:email_provider.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_provider.bulkInsert}}** on **{{label:email_provider}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_provider.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_provider.bulkEdit}}** on **{{label:email_provider}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_provider.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_provider.bulkEditWithFile}}** on **{{label:email_provider}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_provider.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_provider.bulkDelete}}** on **{{label:email_provider}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: process:email_provider_sender_address.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_provider_sender_address.bulkInsert}}** on **{{label:email_provider_sender_address}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_provider_sender_address.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_provider_sender_address.bulkEdit}}** on **{{label:email_provider_sender_address}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_provider_sender_address.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_provider_sender_address.bulkEditWithFile}}** on **{{label:email_provider_sender_address}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_provider_sender_address.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_provider_sender_address.bulkDelete}}** on **{{label:email_provider_sender_address}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: process:email_template.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_template.bulkInsert}}** on **{{label:email_template}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_template.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_template.bulkEdit}}** on **{{label:email_template}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_template.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_template.bulkEditWithFile}}** on **{{label:email_template}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_template.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_template.bulkDelete}}** on **{{label:email_template}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: process:email_template_version.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_template_version.bulkInsert}}** on **{{label:email_template_version}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_template_version.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_template_version.bulkEdit}}** on **{{label:email_template_version}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_template_version.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_template_version.bulkEditWithFile}}** on **{{label:email_template_version}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_template_version.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_template_version.bulkDelete}}** on **{{label:email_template_version}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: process:email_brand.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_brand.bulkInsert}}** on **{{label:email_brand}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_brand.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_brand.bulkEdit}}** on **{{label:email_brand}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_brand.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_brand.bulkEditWithFile}}** on **{{label:email_brand}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_brand.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_brand.bulkDelete}}** on **{{label:email_brand}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: process:email_suppression.bulkInsert;slot:help
summary: Import new records from a file after mapping columns and reviewing validation.
content: |
Use **{{label:email_suppression.bulkInsert}}** on **{{label:email_suppression}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
- key: process:email_suppression.bulkEdit;slot:help
summary: Update selected records with shared field values after reviewing the selection.
content: |
Use **{{label:email_suppression.bulkEdit}}** on **{{label:email_suppression}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
- key: process:email_suppression.bulkEditWithFile;slot:help
summary: Update existing records by matching them to rows in a file.
content: |
Use **{{label:email_suppression.bulkEditWithFile}}** on **{{label:email_suppression}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
- key: process:email_suppression.bulkDelete;slot:help
summary: Delete selected records after reviewing the selection and its dependencies.
content: |
Use **{{label:email_suppression.bulkDelete}}** on **{{label:email_suppression}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
- key: possibleValueSource:email_provider_kind;slot:help
summary: Delivery services and protocols available for provider configuration.
content: |
The available choices are {{values:email_provider_kind}}.
- key: possibleValueSource:email_security;slot:help
summary: Connection security choices for SMTP providers.
content: |
The available choices are {{values:email_security}}.
- key: possibleValueSource:email_health_status;slot:help
summary: Health states recorded for a delivery provider.
content: |
The available choices are {{values:email_health_status}}.
- key: possibleValueSource:email_message_status;slot:help
summary: Delivery states recorded for outgoing messages.
content: |
The available choices are {{values:email_message_status}}.
- key: possibleValueSource:email_attempt_outcome;slot:help
summary: Outcomes of individual sending attempts.
content: |
The available choices are {{values:email_attempt_outcome}}.
- key: possibleValueSource:email_template_kind;slot:help
summary: Source formats available for template content.
content: |
The available choices are {{values:email_template_kind}}.
- key: possibleValueSource:email_version_status;slot:help
summary: Lifecycle states of a template version.
content: |
The available choices are {{values:email_version_status}}.
- key: possibleValueSource:email_suppression_scope;slot:help
summary: Categories of email that an address restriction can block.
content: |
The available choices are {{values:email_suppression_scope}}.
- key: possibleValueSource:email_suppression_reason;slot:help
summary: Reasons an address can be blocked from receiving email.
content: |
The available choices are {{values:email_suppression_reason}}.
- key: possibleValueSource:email_category;slot:help
summary: Message categories used for suppression and unsubscribe rules.
content: |
The available choices are {{values:email_category}}.
- key: possibleValueSource:email_inbox_status;slot:help
summary: Processing states for received provider notifications.
content: |
The available choices are {{values:email_inbox_status}}.
- key: possibleValueSource:email_event_type;slot:help
summary: Kinds of delivery and engagement events reported by providers.
content: |
The available choices are {{values:email_event_type}}.
- key: possibleValueSource:email_bounce_type;slot:help
summary: Bounce classifications reported by providers.
content: |
The available choices are {{values:email_bounce_type}}.
- key: possibleValueSource:email_brand;slot:help
summary: Select the branding for email content.
- key: possibleValueSource:email_event;slot:help
summary: Select a delivery or engagement event.
- key: possibleValueSource:email_message;slot:help
summary: Select an outgoing message.
- key: possibleValueSource:email_message_attempt;slot:help
summary: Select a sending attempt.
- key: possibleValueSource:email_provider;slot:help
summary: Select a delivery provider.
- key: possibleValueSource:email_provider_sender_address;slot:help
summary: Select a sender address for a delivery provider.
- key: possibleValueSource:email_suppression;slot:help
summary: Select an address restriction.
- key: possibleValueSource:email_template;slot:help
summary: Select a reusable email template.
- key: possibleValueSource:email_template_version;slot:help
summary: Select a version of an email template.
- key: possibleValueSource:email_webhook_inbox;slot:help
summary: Select a received provider notification.
@@ -0,0 +1,309 @@
/*
* 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.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.ContrastCheck;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped starter defaults (db/liquibase/qbit-email/email-defaults.
** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against
** the qbit's test schema: the brand appears once, a second update runs nothing,
** an edit survives, a host with its own brand or without the table is left
** alone, and the seed never changes how mail is rendered.
*******************************************************************************/
class EmailDefaultsChangelogTest
{
static final String CHANGELOG = "db/liquibase/qbit-email/email-defaults.changelog.sql";
/** the tables the changelog seeds. */
static final List<String> SEEDED_TABLES = List.of("email_brand");
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Fresh schema, update twice: one brand; an edit survives.
*******************************************************************************/
@Test
void seedsAFreshSchemaInsertOnly() throws Exception
{
try(Connection connection = freshDatabase(true))
{
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("neutral");
assertThat(scalar(connection, "SELECT is_default FROM email_brand")).isEqualTo("FALSE");
try(Statement statement = connection.createStatement())
{
statement.execute("UPDATE email_brand SET label = 'Ours', is_default = TRUE");
}
connection.commit();
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
assertThat(scalar(connection, "SELECT label FROM email_brand")).isEqualTo("Ours");
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("1");
}
}
/*******************************************************************************
** A host that already configured a brand (of any name) gets nothing added,
** so it can never end up with two default brands.
*******************************************************************************/
@Test
void leavesAHostWithItsOwnBrandAlone() throws Exception
{
try(Connection connection = freshDatabase(true))
{
try(Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('acme', 'Acme', TRUE)");
}
connection.commit();
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("acme");
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
}
}
/*******************************************************************************
** Rolling the seed back (Liquibase also rolls back changesets it only marked
** as ran) never deletes a brand the host already had.
*******************************************************************************/
@Test
void rollbackKeepsHostOwnedRows() throws Exception
{
try(Connection connection = freshDatabase(true))
{
try(Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('neutral', 'Mine', TRUE)");
}
connection.commit();
update(connection);
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(1, "");
connection.commit();
assertThat(scalar(connection, "SELECT label FROM email_brand WHERE name = 'neutral'")).isEqualTo("Mine");
}
}
/*******************************************************************************
** A host that has not created the table yet skips the changeset (marked
** ran), rather than failing its deploy.
*******************************************************************************/
@Test
void aMissingTableMarksTheChangesetRan() throws Exception
{
try(Connection connection = freshDatabase(false))
{
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
}
}
/*******************************************************************************
** Guarded, and only precondition forms that parse in Liquibase 4 and 5.
*******************************************************************************/
@Test
void everyChangesetIsGuarded() throws Exception
{
String text = changelogText();
assertThat(text).doesNotContain("precondition-table-exists");
assertThat(text.split("(?m)^-- changeset ")).hasSize(2);
assertThat(text).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand")
.contains("-- rollback ").contains("WHERE NOT EXISTS (SELECT 1 FROM email_brand)");
for(String table : SEEDED_TABLES)
{
assertThat(text).contains("INSERT INTO " + table + " ");
}
}
/*******************************************************************************
** The seeded brand passes the qbit's own contrast checks, and being a
** non-default brand it changes nothing about how mail renders until chosen.
*******************************************************************************/
@Test
void theSeededBrandIsSafeAndChangesNothing() throws Exception
{
try(Connection connection = freshDatabase(true))
{
update(connection);
QRecord brand = new QRecord();
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM email_brand"))
{
rs.next();
for(String column : List.of("primary_color", "text_color", "background_color", "button_text_color", "dark_primary_color", "dark_text_color", "dark_background_color", "dark_button_text_color"))
{
brand.setValue(camel(column), rs.getString(column));
}
}
ContrastCheck.validate(brand);
EmailBrands.validate(brand);
}
H2Support.setup(config -> config.withSecretResolver(name -> null));
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "neutral").withValue("label", "Neutral").withValue("primaryColor", "#2459a6")
.withValue("isDefault", false));
QRecord resolved = EmailBrands.resolve(null, null);
assertThat(resolved.getValue("id")).as("the seeded brand is not picked up unless selected").isNull();
assertThat(resolved.getValueString("label")).isEqualTo("Email");
}
/*******************************************************************************
** snake_case to camelCase.
*******************************************************************************/
private static String camel(String column)
{
StringBuilder camel = new StringBuilder();
boolean up = false;
for(char c : column.toCharArray())
{
if(c == '_')
{
up = true;
}
else
{
camel.append(up ? Character.toUpperCase(c) : c);
up = false;
}
}
return (camel.toString());
}
/*******************************************************************************
** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables.
*******************************************************************************/
static Connection freshDatabase(boolean withTables) throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
if(withTables)
{
String script;
try(InputStream in = EmailDefaultsChangelogTest.class.getResourceAsStream("/email-h2.sql"))
{
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
try(Statement statement = connection.createStatement())
{
for(String sql : script.split(";"))
{
if(!sql.isBlank())
{
statement.execute(sql);
}
}
}
}
return (connection);
}
/*******************************************************************************
** Run the changelog with real Liquibase.
*******************************************************************************/
static void update(Connection connection) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
//////////////////////////////////////////////////////////////////
// not closed: closing Liquibase would close the test connection //
//////////////////////////////////////////////////////////////////
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update("");
connection.commit();
}
/*******************************************************************************
**
*******************************************************************************/
static String changelogText() throws Exception
{
try(InputStream stream = EmailDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG))
{
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String scalar(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
{
rs.next();
return (rs.getString(1));
}
}
}
@@ -108,7 +108,7 @@ class EmailHomePolishTest
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(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
}
@@ -119,9 +119,10 @@ class EmailHomePolishTest
void stackedBarsAndCompactAxes() throws Exception
{
int number = 0;
Integer messageId = SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("status", "QUEUED")).getValueInteger("id");
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)
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", messageId).withValue("attemptNumber", ++number)
.withValue("outcome", outcome).withValue("endDate", NOW));
}
for(var reason : EmailEnums.SuppressionReason.values())
@@ -167,7 +168,7 @@ class EmailHomePolishTest
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");
assertThat(document.select("section").get(2).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"));
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.email;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -49,6 +50,27 @@ class EmailQBitProducerTest
assertThat(producer.getQBitConfig().getBackendName()).isEqualTo("memory");
assertThat(producer.produce(instance)).isNotNull();
assertThat(instance.getQBits()).isNotEmpty();
assertThat(instance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-email");
assertThat(bundle.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
});
}
/*******************************************************************************
** The app help follows the configured app name even without a help table.
*******************************************************************************/
@Test
void registersHelpForCustomAppName() throws Exception
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory"));
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withAppName("mail"))
.produce(instance);
assertThat(HelpBundles.of(instance).resolveWinningEntries())
.containsKey(new HelpBundles.EntryKey("app:mail;slot:help", "ALL_SCREENS"))
.doesNotContainKey(new HelpBundles.EntryKey("app:email;slot:help", "ALL_SCREENS"));
}
/*******************************************************************************
@@ -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;
import java.util.List;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qbits.email.app.EmailHome;
import com.kingsrook.qbits.email.app.EmailSetupStatus;
import com.kingsrook.qbits.email.app.EmailSetupStatus.Item;
import com.kingsrook.qbits.email.app.EmailSetupStatus.State;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.template.EmailBuiltIns;
import com.kingsrook.qqq.backend.core.context.QContext;
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;
/*******************************************************************************
** The Setup card follows the real configuration (the same provider and brand
** resolution the sender uses), the home creates the built-in template, and the
** template's owner qbit now decides who a message names as its sender.
*******************************************************************************/
class EmailSetupTest
{
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(config -> config.withSecretResolver(name -> null));
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private Item item(String label) throws Exception
{
return (EmailSetupStatus.load().stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
}
/*******************************************************************************
** Nothing configured: the provider is the one thing that must be added.
*******************************************************************************/
@Test
void freshInstallNeedsAProvider() throws Exception
{
assertThat(item("Provider").state()).isEqualTo(State.TODO);
assertThat(item("Brands").state()).isEqualTo(State.DEFAULT);
assertThat(item("Sender addresses").state()).isEqualTo(State.OPTIONAL);
assertThat(item("Marketing email").state()).isEqualTo(State.OPTIONAL);
assertThat(item("Scheduler").state()).isEqualTo(State.TODO);
assertThat(EmailSetupStatus.load()).allMatch(i -> i.tableName() == null || i.tableName().startsWith("email_"));
}
/*******************************************************************************
** Configuring a part moves its line, using the sender's own resolution.
*******************************************************************************/
@Test
void configuringMovesTheState() throws Exception
{
H2Support.provider(25);
assertThat(item("Provider").state()).isEqualTo(State.OK);
assertThat(item("Delivery tracking").state()).isEqualTo(State.OPTIONAL);
assertThat(EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL).ready()).isTrue();
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "acme").withValue("label", "Acme").withValue("isDefault", true));
assertThat(item("Brands").state()).isEqualTo(State.OK);
}
/*******************************************************************************
** The home shows the card to admins and creates the built-in template.
*******************************************************************************/
@Test
void homeShowsTheCardAndCreatesTheBuiltInTemplate() throws Exception
{
assertThat(H2Support.all(EmailTables.TEMPLATE)).isEmpty();
String html = EmailHome.overview().getHtml();
assertThat(html).contains("Setup").contains("Needs setup").contains("href=\"/app/email_provider\"").contains("Next step");
assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("name")).isEqualTo("email_secure_message");
assertThat(row.getValueString("ownerQbit")).isEqualTo(EmailBuiltIns.OWNER);
});
}
/*******************************************************************************
** The registering qbit becomes the message's sender unless the request names one.
*******************************************************************************/
@Test
void theTemplatesOwnerQbitIsTheDefaultSender() throws Exception
{
H2Support.provider(25);
EmailApi.ensureTemplate(new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Ready").withBody("Hello").withOwnerQbit("orders"));
var plain = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-1"));
var named = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-2").withSender("billing", "invoices"));
List<QRecord> rows = H2Support.all(EmailTables.MESSAGE);
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(plain.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("orders"));
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(named.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("billing"));
}
}
@@ -0,0 +1,178 @@
/*
* 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.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated
** and the qbit should be fully using them"). For every email_* table:
** - runtime code (not just metadata, the app or the home) reads or writes it;
** - it is either seeded by the shipped defaults changelog, or listed here with
** why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class EmailTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "email");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("email_provider", "host connection config: a server and the NAMES of its secret variables (kof22 seeds its provider)"),
Map.entry("email_provider_sender_address", "optional: a provider's own from address is used without rows"),
Map.entry("email_message", "runtime: every queued email"),
Map.entry("email_message_attempt", "runtime: every delivery attempt"),
Map.entry("email_template", "created at runtime: the secure-message built-in on first use (EmailBuiltIns), others by the qbits that register them (EmailApi.ensureTemplate)"),
Map.entry("email_template_version", "created with a template, and by New Version"),
Map.entry("email_suppression", "runtime: bounces, complaints, unsubscribes and manual blocks"),
Map.entry("email_webhook_inbox", "runtime: verified provider callbacks"),
Map.entry("email_event", "runtime: delivery tracking events"));
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
.map(EmailTablesGuardrailTest::read).toList();
}
for(String table : EmailTables.ALL)
{
String constant = constantFor(table);
assertThat(runtimeSource.stream().anyMatch(source -> source.contains("EmailTables." + constant)))
.as(table + " (EmailTables." + constant + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
String changelog = EmailDefaultsChangelogTest.changelogText();
for(String table : EmailTables.ALL)
{
boolean seeded = EmailDefaultsChangelogTest.SEEDED_TABLES.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
HelpBundleAssertions.forInstance(instance).bundles("help/qbit-email.yaml")
.owning(target -> target.name().startsWith("email"))
.assertComplete();
var entries = HelpBundles.of(instance).resolveWinningEntries();
for(String table : EmailTables.ALL)
{
var help = entries.get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
assertThat(help).as(table + " table help").isNotNull();
assertThat(help.entry().getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(entries).containsKey(new HelpBundles.EntryKey("app:email;slot:help", "ALL_SCREENS"));
}
/*******************************************************************************
** The EmailTables constant name for a table name.
*******************************************************************************/
private static String constantFor(String table) throws Exception
{
for(var field : EmailTables.class.getDeclaredFields())
{
if(field.getType() == String.class && Modifier.isStatic(field.getModifiers()) && table.equals(field.get(null)))
{
return (field.getName());
}
}
throw (new IllegalStateException("No constant for " + table));
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,460 @@
/*
* 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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Referential integrity guardrail: docs/ddl/email-postgres.sql, applied to
** a real PostgreSQL, has a foreign key for every join and every table
** possible-value field between the qbit's tables (or the relation is in
** {@link #EXEMPT} with a reason), each with the intended ON DELETE. A new
** table or join without its key fails here. The upgrade script is checked
** too: it quarantines orphans, adds the same keys, and is idempotent.
*******************************************************************************/
class ForeignKeyGuardrailTest
{
private static final Path DDL = Path.of("docs/ddl/email-postgres.sql");
private static final Path UPGRADE = Path.of("docs/ddl/email-foreign-keys-upgrade.sql");
/////////////////////////////////////////////////////////////////////////////
// child_table.child_column -> parent_table : ON DELETE (pg confdeltype) //
// c = CASCADE, n = SET NULL, r = RESTRICT //
/////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> EXPECTED = new TreeMap<>(Map.ofEntries(
Map.entry("email_provider_sender_address.provider_id -> email_provider", "c"),
Map.entry("email_message.provider_id -> email_provider", "r"),
Map.entry("email_message.from_address_id -> email_provider_sender_address", "n"),
Map.entry("email_message.template_id -> email_template", "n"),
Map.entry("email_message.template_version_id -> email_template_version", "n"),
Map.entry("email_message.brand_id -> email_brand", "n"),
Map.entry("email_message_attempt.message_id -> email_message", "c"),
Map.entry("email_message_attempt.provider_id -> email_provider", "r"),
Map.entry("email_template.brand_id -> email_brand", "r"),
Map.entry("email_template.active_version_id -> email_template_version", "n"),
Map.entry("email_template.approved_version_id -> email_template_version", "n"),
Map.entry("email_template_version.template_id -> email_template", "r"),
Map.entry("email_suppression.source_message_id -> email_message", "n"),
Map.entry("email_suppression.source_event_id -> email_event", "n"),
Map.entry("email_webhook_inbox.provider_id -> email_provider", "r"),
Map.entry("email_event.message_id -> email_message", "c"),
Map.entry("email_event.provider_id -> email_provider", "r")));
/////////////////////////////////////////////////////////////////////////////
// keys for relations that exist only in code (no join or PVS in metadata) //
/////////////////////////////////////////////////////////////////////////////
private static final List<String> CODE_ONLY = List.of(
"email_suppression.source_message_id -> email_message");
/////////////////////////////////////////////////////////////////////////////
// relations that are deliberately not foreign keys, with the reason //
/////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> EXEMPT = Map.of();
private static PostgreSQLContainer postgres;
/*******************************************************************************
** Start PostgreSQL once (a missing Docker fails in CI, skips locally).
*******************************************************************************/
@BeforeAll
static void startPostgres()
{
boolean docker;
try
{
docker = DockerClientFactory.instance().isDockerAvailable();
}
catch(RuntimeException e)
{
docker = false;
}
if(!docker && System.getenv("CI") != null)
{
Assertions.fail("Docker is required for the foreign key guardrail in CI");
}
Assumptions.assumeTrue(docker, "Docker is not available");
postgres = new PostgreSQLContainer("postgres:17-alpine").withDatabaseName("email").withUsername("qqq").withPassword("qqq");
postgres.start();
}
/*******************************************************************************
** Stop PostgreSQL.
*******************************************************************************/
@AfterAll
static void stopPostgres()
{
if(postgres != null)
{
postgres.stop();
}
}
/*******************************************************************************
** A clean schema from the reference DDL.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
try(Connection c = connect(); Statement st = c.createStatement())
{
st.execute("DROP SCHEMA public CASCADE");
st.execute("CREATE SCHEMA public");
st.execute(Files.readString(DDL, StandardCharsets.UTF_8));
}
}
/*******************************************************************************
** Clear the QContext the metadata setup opened.
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/*******************************************************************************
** Every declared relation is a foreign key (or exempt), with the intended
** ON DELETE, and every foreign key column leads an index.
*******************************************************************************/
@Test
void everyRelationHasAForeignKey() throws Exception
{
TreeSet<String> declared = declaredRelations(H2Support.setup(config ->
{
}));
Map<String, String> keys = foreignKeys();
List<String> missing = new ArrayList<>();
for(String relation : declared)
{
if(!keys.containsKey(relation) && !EXEMPT.containsKey(relation))
{
missing.add(relation);
}
}
assertThat(missing).as("relations declared in metadata without a foreign key in " + DDL + " (add one, or exempt it with a reason)").isEmpty();
assertThat(declared).as("every expected relation is still declared").containsAll(EXPECTED.keySet().stream().filter(k -> !CODE_ONLY.contains(k)).toList());
assertThat(keys).as("foreign keys and their ON DELETE").isEqualTo(EXPECTED);
assertThat(unindexedForeignKeys()).as("foreign key columns that lead no index").isEmpty();
}
/*******************************************************************************
** The upgrade script adds the same keys to a schema without them, recording
** and removing orphans first, and is a no-op the second time.
*******************************************************************************/
@Test
void upgradeQuarantinesOrphansAndIsIdempotent() throws Exception
{
try(Connection c = connect(); Statement st = c.createStatement())
{
dropForeignKeys(st);
st.execute("""
INSERT INTO email_provider (id, name) VALUES (1, 'relay');
INSERT INTO email_provider_sender_address (id, provider_id, address) VALUES (1, 1, 'a@example.com'), (2, 99, 'b@example.com');
INSERT INTO email_message (id, provider_id, from_address_id, template_id, brand_id) VALUES (1, 1, 1, NULL, NULL), (2, 1, 2, 98, 97);
INSERT INTO email_message_attempt (id, message_id, attempt_number, provider_id) VALUES (1, 1, 1, 1), (2, 96, 1, 1);
INSERT INTO email_event (id, message_id, provider_id, event_type, event_key) VALUES (1, 1, 1, 'DELIVERED', 'k1'), (2, 95, 1, 'BOUNCED', 'k2');
INSERT INTO email_suppression (id, address, scope, source_message_id, source_event_id) VALUES (1, 'x@example.com', 'ALL', 95, 2);
INSERT INTO email_webhook_inbox (id, provider_id, kind, external_event_id, status) VALUES (1, 94, 'SES', 'e1', 'PENDING');
""");
st.execute(Files.readString(UPGRADE, StandardCharsets.UTF_8));
assertThat(foreignKeys()).isEqualTo(EXPECTED);
assertThat(quarantine(st)).containsExactlyInAnyOrder(
"email_provider_sender_address.provider_id row 2 parent 99 DELETED",
"email_message.from_address_id row 2 parent 2 NULLED",
"email_message.template_id row 2 parent 98 NULLED",
"email_message.brand_id row 2 parent 97 NULLED",
"email_message_attempt.message_id row 2 parent 96 DELETED",
"email_event.message_id row 2 parent 95 DELETED",
"email_webhook_inbox.provider_id row 1 parent 94 DELETED",
"email_suppression.source_message_id row 1 parent 95 NULLED",
"email_suppression.source_event_id row 1 parent 2 NULLED");
assertThat(count(st, "SELECT COUNT(*) FROM email_suppression")).isEqualTo(1);
st.execute(Files.readString(UPGRADE, StandardCharsets.UTF_8));
assertThat(foreignKeys()).isEqualTo(EXPECTED);
assertThat(quarantine(st)).hasSize(9);
}
}
/*******************************************************************************
** Message retention (events, attempts, then the message) keeps the
** suppression; the template/version cycle inserts in the qbit's order; a
** provider with history, a brand in use and a template with versions
** cannot be deleted.
*******************************************************************************/
@Test
void deletesFollowTheIntendedRules() throws Exception
{
try(Connection c = connect(); Statement st = c.createStatement())
{
st.execute("""
INSERT INTO email_provider (id, name) VALUES (1, 'relay'), (2, 'spare');
INSERT INTO email_provider_sender_address (provider_id, address) VALUES (2, 'b@example.com');
INSERT INTO email_brand (id, name) VALUES (1, 'default');
INSERT INTO email_template (id, name, brand_id) VALUES (1, 'welcome', 1);
INSERT INTO email_template_version (id, template_id, version_number, locale) VALUES (1, 1, 1, 'en'), (2, 1, 2, 'en');
UPDATE email_template SET active_version_id = 1, approved_version_id = 1 WHERE id = 1;
INSERT INTO email_message (id, provider_id, template_id, template_version_id, brand_id) VALUES (1, 1, 1, 1, 1);
INSERT INTO email_message_attempt (message_id, attempt_number, provider_id) VALUES (1, 1, 1);
INSERT INTO email_event (id, message_id, provider_id, event_type, event_key) VALUES (1, 1, 1, 'BOUNCED', 'k1');
INSERT INTO email_suppression (address, scope, source_message_id, source_event_id) VALUES ('x@example.com', 'ALL', 1, 1);
""");
assertThatThrownBy(() -> st.execute("DELETE FROM email_provider WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_");
assertThatThrownBy(() -> st.execute("DELETE FROM email_template WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_template_version_template_id");
assertThatThrownBy(() -> st.execute("DELETE FROM email_brand WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_template_brand_id");
st.execute("DELETE FROM email_template_version WHERE id = 2");
st.execute("DELETE FROM email_provider WHERE id = 2");
assertThat(count(st, "SELECT COUNT(*) FROM email_provider_sender_address")).isZero();
st.execute("DELETE FROM email_event WHERE message_id = 1");
st.execute("DELETE FROM email_message_attempt WHERE message_id = 1");
st.execute("DELETE FROM email_message WHERE id = 1");
assertThat(count(st, "SELECT COUNT(*) FROM email_suppression WHERE source_message_id IS NULL AND source_event_id IS NULL")).isEqualTo(1);
}
}
/*******************************************************************************
** child_table.child_column -> parent_table for every join and table PVS
** field between two of the qbit's tables.
*******************************************************************************/
static TreeSet<String> declaredRelations(QInstance instance)
{
TreeSet<String> relations = new TreeSet<>();
for(QTableMetaData table : instance.getTables().values())
{
if(!isQbitTable(table))
{
continue;
}
for(QFieldMetaData field : table.getFields().values())
{
QPossibleValueSource pvs = field.getPossibleValueSourceName() == null ? null : instance.getPossibleValueSource(field.getPossibleValueSourceName());
if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && isQbitTable(instance.getTable(pvs.getTableName())))
{
relations.add(relation(table, field, instance.getTable(pvs.getTableName())));
}
}
}
for(QJoinMetaData join : instance.getJoins().values())
{
QTableMetaData left = instance.getTable(join.getLeftTable());
QTableMetaData right = instance.getTable(join.getRightTable());
if(!isQbitTable(left) || !isQbitTable(right))
{
continue;
}
for(JoinOn on : join.getJoinOns())
{
if(on.getLeftField().equals(left.getPrimaryKeyField()))
{
relations.add(relation(right, right.getField(on.getRightField()), left));
}
else if(on.getRightField().equals(right.getPrimaryKeyField()))
{
relations.add(relation(left, left.getField(on.getLeftField()), right));
}
}
}
return (relations);
}
/*******************************************************************************
** Whether a table is one of the qbit's.
*******************************************************************************/
private static boolean isQbitTable(QTableMetaData table)
{
return (table != null && table.getName().startsWith("email_"));
}
/*******************************************************************************
** A relation key.
*******************************************************************************/
private static String relation(QTableMetaData child, QFieldMetaData field, QTableMetaData parent)
{
String column = field.getBackendName() != null ? field.getBackendName() : field.getName();
return (child.getName() + "." + column + " -> " + parent.getName());
}
/*******************************************************************************
** The single-column foreign keys in the database, with their ON DELETE.
*******************************************************************************/
private static Map<String, String> foreignKeys() throws SQLException
{
Map<String, String> keys = new TreeMap<>();
try(Connection c = connect(); Statement st = c.createStatement(); ResultSet rs = st.executeQuery("""
SELECT c.conrelid::regclass::text AS child, a.attname AS col, c.confrelid::regclass::text AS parent, c.confdeltype::text AS rule
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f' AND cardinality(c.conkey) = 1
"""))
{
while(rs.next())
{
keys.put(rs.getString("child") + "." + rs.getString("col") + " -> " + rs.getString("parent"), rs.getString("rule"));
}
}
return (keys);
}
/*******************************************************************************
** Foreign key columns that are not the first column of any index.
*******************************************************************************/
private static List<String> unindexedForeignKeys() throws SQLException
{
List<String> list = new ArrayList<>();
try(Connection c = connect(); Statement st = c.createStatement(); ResultSet rs = st.executeQuery("""
SELECT c.conrelid::regclass::text || '.' || a.attname AS col
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f'
AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1])
"""))
{
while(rs.next())
{
list.add(rs.getString("col"));
}
}
return (list);
}
/*******************************************************************************
** Drop every foreign key (a schema as it was before they were declared).
*******************************************************************************/
private static void dropForeignKeys(Statement st) throws SQLException
{
List<String> drops = new ArrayList<>();
try(ResultSet rs = st.executeQuery("SELECT conrelid::regclass::text AS t, conname FROM pg_constraint WHERE contype = 'f'"))
{
while(rs.next())
{
drops.add("ALTER TABLE " + rs.getString("t") + " DROP CONSTRAINT " + rs.getString("conname"));
}
}
for(String drop : drops)
{
st.execute(drop);
}
}
/*******************************************************************************
** The quarantine rows, as text.
*******************************************************************************/
private static List<String> quarantine(Statement st) throws SQLException
{
List<String> rows = new ArrayList<>();
try(ResultSet rs = st.executeQuery("SELECT table_name, column_name, row_id, missing_parent_id, action FROM qqq_fk_quarantine ORDER BY id"))
{
while(rs.next())
{
rows.add(rs.getString(1) + "." + rs.getString(2) + " row " + rs.getString(3) + " parent " + rs.getString(4) + " " + rs.getString(5));
}
}
return (rows);
}
/*******************************************************************************
** A count.
*******************************************************************************/
private static int count(Statement st, String sql) throws SQLException
{
try(ResultSet rs = st.executeQuery(sql))
{
rs.next();
return (rs.getInt(1));
}
}
/*******************************************************************************
** A connection.
*******************************************************************************/
private static Connection connect() throws SQLException
{
return (DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"));
}
}
@@ -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.api;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** No ArchUnit dependency: inspect every compiled API class and nested type,
** including generic arguments, bounds, arrays, exceptions and inheritance.
*******************************************************************************/
class EmailApiBoundaryTest
{
/*******************************************************************************
** Discovery uses production output so new API classes cannot escape review.
*******************************************************************************/
@Test
void publishedSignaturesUseOnlyApiJdkAndQqqCoreTypes() throws Exception
{
Path root = Path.of(EmailApi.class.getProtectionDomain().getCodeSource().getLocation().toURI());
Path packagePath = root.resolve("com/kingsrook/qbits/email/api");
int count = 0;
try(var files = Files.walk(packagePath))
{
for(Path path : files.filter(p -> p.toString().endsWith(".class")).toList())
{
String name = root.relativize(path).toString().replace('\\', '.').replace('/', '.').replaceFirst("\\.class$", "");
inspect(Class.forName(name));
count++;
}
}
assertThat(count).isGreaterThanOrEqualTo(11);
}
/*******************************************************************************
** Prove the guard catches indirect leaks, not just raw return classes.
*******************************************************************************/
@Test
void rejectsNestedGenericAndProtectedLeaks() throws Exception
{
assertThatThrownBy(() -> inspect(LeakingGeneric.class)).isInstanceOf(AssertionError.class);
assertThatThrownBy(() -> inspect(LeakingProtected.class)).isInstanceOf(AssertionError.class);
}
/*******************************************************************************
** Constructors, fields, inherited methods and protected declarations count.
*******************************************************************************/
private void inspect(Class<?> apiClass)
{
Set<Type> seen = new HashSet<>();
check(apiClass.getGenericSuperclass(), seen);
checkAll(apiClass.getGenericInterfaces(), seen);
checkAll(apiClass.getTypeParameters(), seen);
for(var field : apiClass.getDeclaredFields())
{
if(visible(field.getModifiers()))
{
check(field.getGenericType(), seen);
}
}
for(var constructor : apiClass.getDeclaredConstructors())
{
if(visible(constructor.getModifiers()))
{
checkAll(constructor.getGenericParameterTypes(), seen);
checkAll(constructor.getGenericExceptionTypes(), seen);
checkAll(constructor.getTypeParameters(), seen);
}
}
Set<java.lang.reflect.Method> methods = new HashSet<>(java.util.List.of(apiClass.getDeclaredMethods()));
methods.addAll(java.util.List.of(apiClass.getMethods()));
for(var method : methods)
{
if(visible(method.getModifiers()))
{
check(method.getGenericReturnType(), seen);
checkAll(method.getGenericParameterTypes(), seen);
checkAll(method.getGenericExceptionTypes(), seen);
checkAll(method.getTypeParameters(), seen);
}
}
}
/*******************************************************************************
** Public and protected form the published contract.
*******************************************************************************/
private boolean visible(int modifiers)
{
return (Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers));
}
/*******************************************************************************
** Walk each type without looping on recursive type bounds.
*******************************************************************************/
private void checkAll(Type[] types, Set<Type> seen)
{
for(Type type : types)
{
check(type, seen);
}
}
/*******************************************************************************
** Generic containers cannot hide an implementation or third-party type.
*******************************************************************************/
private void check(Type type, Set<Type> seen)
{
if(type == null || !seen.add(type))
{
return;
}
if(type instanceof Class<?> clazz)
{
if(clazz.isArray())
{
check(clazz.getComponentType(), seen);
}
else
{
String name = clazz.getName();
String module = clazz.getModule().getName();
boolean jdk = module != null && (module.startsWith("java.") || module.startsWith("jdk."));
assertThat(clazz.isPrimitive() || jdk
|| name.startsWith("com.kingsrook.qqq.backend.core.") || name.startsWith("com.kingsrook.qbits.email.api."))
.as("Published signature type %s", name).isTrue();
}
}
else if(type instanceof ParameterizedType parameterized)
{
check(parameterized.getRawType(), seen);
check(parameterized.getOwnerType(), seen);
checkAll(parameterized.getActualTypeArguments(), seen);
}
else if(type instanceof TypeVariable<?> variable)
{
checkAll(variable.getBounds(), seen);
}
else if(type instanceof WildcardType wildcard)
{
checkAll(wildcard.getUpperBounds(), seen);
checkAll(wildcard.getLowerBounds(), seen);
}
else if(type instanceof GenericArrayType array)
{
check(array.getGenericComponentType(), seen);
}
else
{
throw (new AssertionError("Unhandled signature type: " + type));
}
}
/*******************************************************************************
** Deliberately invalid fixture, outside production class discovery.
*******************************************************************************/
private static class LeakingGeneric
{
/*******************************************************************************
** A wildcard and array must not disguise an internal request type.
*******************************************************************************/
public java.util.List<? extends com.kingsrook.qbits.email.outbox.EmailRequest[]> leak()
{
return (null);
}
}
/*******************************************************************************
** Protected fields are also part of the API contract.
*******************************************************************************/
private static class LeakingProtected
{
protected com.kingsrook.qbits.email.EmailQBitConfig config;
}
}
@@ -0,0 +1,559 @@
/*
* 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.api;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailEffects;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.support.EmailApiMapper;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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;
/*******************************************************************************
** Stable API mapping and behavior against the real outbox and H2 database.
*******************************************************************************/
class EmailApiTest
{
private QInstance instance;
private QRecord provider;
/*******************************************************************************
** No environment secrets are used by these tests.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
provider = H2Support.provider(25);
}
/*******************************************************************************
** Release thread-local context.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Every request property reaches the internal request with the same value.
*******************************************************************************/
@Test
void mapsEveryRequestField() throws Exception
{
Instant after = Instant.parse("2030-01-02T03:04:05Z");
EmailApiRequest request = new EmailApiRequest().withTo(new ArrayList<>(List.of("to@example.com"))).withTo("second@example.com")
.withCc(new ArrayList<>(List.of("cc@example.com"))).withCc("cc2@example.com")
.withBcc(new ArrayList<>(List.of("bcc@example.com"))).withBcc("bcc2@example.com")
.withReplyTo("reply@example.com").withFrom("from@example.com").withFromAddressId(8)
.withSubject("Subject").withMarkdownBody("Markdown").withHtmlBody("<p>HTML</p>").withTextBody("Text")
.withTemplateName("notice").withVariables(new java.util.LinkedHashMap<>(Map.of("number", 7)))
.withVariable("label", "value").withRecordVariables(new java.util.LinkedHashMap<>())
.withRecordVariable("order", "orders", 42).withCategory(EmailApiCategory.SECURITY)
.withProviderName("named").withBrandName("brand").withLocale("fr").withIdempotencyKey("api:42")
.withLinkedTableName("old").withLinkedRecordId("old").withLinkedRecord("orders", 42)
.withSenderQbit("old").withSenderProcess("old").withSender("orders", "complete").withSendAfter(after)
.withTransaction(new QBackendTransaction(), "rdbms");
EmailRequest mapped = EmailApiMapper.request(request);
for(Method getter : EmailApiRequest.class.getDeclaredMethods())
{
if(!getter.getName().startsWith("get"))
{
continue;
}
Object value = getter.invoke(request);
assertThat(value).as(getter.getName()).isNotNull();
if(getter.getName().equals("getCategory"))
{
assertThat(mapped.getCategory().name()).isEqualTo(request.getCategory().name());
}
else if(getter.getName().equals("getRecordVariables"))
{
assertThat(mapped.getRecordVariables()).hasSize(1);
assertThat(mapped.getRecordVariables().get("order").table()).isEqualTo("orders");
assertThat(mapped.getRecordVariables().get("order").id()).isEqualTo(42);
}
else
{
assertThat(EmailRequest.class.getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(value);
}
}
assertThat(mapped.getTo()).containsExactly("to@example.com", "second@example.com");
assertThat(mapped.getLinkedRecordId()).isEqualTo("42");
assertThat(mapped.getSenderQbit()).isEqualTo("orders");
assertThat(new EmailApiRequest().withLinkedRecord("orders", null).getLinkedRecordId()).isNull();
assertThat(EmailApiMapper.request(new EmailApiRequest()).getCategory()).isNull();
assertThat(EmailApiMapper.request(new EmailApiRequest()).getLocale()).isEqualTo("en");
for(EmailApiCategory category : EmailApiCategory.values())
{
assertThat(EmailApiMapper.request(new EmailApiRequest().withCategory(category)).getCategory()).isEqualTo(EmailCategory.valueOf(category.name()));
}
}
/*******************************************************************************
** All statuses and refusal reasons survive mapping without sharing lists.
*******************************************************************************/
@Test
void mapsEveryStatus()
{
for(EmailSendResult.Status status : EmailSendResult.Status.values())
{
List<String> reasons = new ArrayList<>(List.of("Reason"));
EmailApiResult result = EmailApiMapper.result(new EmailSendResult(42, status, reasons));
reasons.clear();
assertThat(result.messageId()).isEqualTo(42);
assertThat(result.status()).isEqualTo(EmailApiStatus.valueOf(status.name()));
assertThat(result.reasons()).containsExactly("Reason");
assertThatThrownBy(() -> result.reasons().clear()).isInstanceOf(UnsupportedOperationException.class);
}
}
/*******************************************************************************
** Sends keep validation, idempotency, suppression and permission behavior.
*******************************************************************************/
@Test
void sendsThroughExistingService() throws Exception
{
EmailApiResult queued = EmailApi.send(request("same"));
assertThat(queued.status()).isEqualTo(EmailApiStatus.QUEUED);
assertThat(EmailApi.send(request("same")).status()).isEqualTo(EmailApiStatus.DUPLICATE);
assertThat(EmailApi.send(request("same")).messageId()).isEqualTo(queued.messageId());
assertThat(EmailApi.send(null).status()).isEqualTo(EmailApiStatus.REFUSED);
assertThat(EmailApi.send(request("bad").withSubject("bad\nsubject")).status()).isEqualTo(EmailApiStatus.REFUSED);
SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com")
.withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL"));
assertThat(EmailApi.send(request("suppressed")).status()).isEqualTo(EmailApiStatus.SUPPRESSED);
instance.setEnforcePermissionsInCoreActions(true);
QContext.getQSession().setPermissions(Set.of());
assertThat(EmailApi.send(request("denied")).reasons()).contains("Sending email needs email.send");
assertThat(EmailApi.getMessage(queued.messageId())).isPresent();
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("same", 1)).hasSize(1);
}
/*******************************************************************************
** Durable handoffs and internal replays agree on the committed row and header.
*******************************************************************************/
@Test
void sendsOnceAndReconcilesCurrentMetadata() throws Exception
{
String key = "job:2:row:email_%";
EmailApiRequest request = request(null).withLinkedRecord("orders", 42);
EmailApiEffectResult first = EmailApi.sendOnce(request, key);
assertThat(request.getIdempotencyKey()).isEqualTo(key);
assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE);
assertThat(first.message().status()).isEqualTo("QUEUED");
assertThat(first.message().idempotencyKey()).isEqualTo(key);
assertThat(first.message().rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>");
assertThat(EmailApi.sendOnce(request(key), key)).isEqualTo(first);
var internal = EmailEffects.send(EmailApiMapper.request(request(null)), key);
assertThat(internal.outcome().name()).isEqualTo(first.outcome().name());
assertThat(internal.message().id()).isEqualTo(first.message().id());
assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId());
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
assertThat(EmailApi.getMessage(first.message().id())).contains(first.message());
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("job:", 5)).containsExactly(first.message());
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id())
.withValue("status", "SENT").withValue("providerMessageId", "provider-reference"));
instance.setEnforcePermissionsInCoreActions(true);
QContext.getQSession().setPermissions(Set.of());
EmailApiMessage found = EmailApi.getMessageByIdempotencyKey(key).orElseThrow();
assertThat(found.id()).isEqualTo(first.message().id());
assertThat(found.status()).isEqualTo("SENT");
assertThat(found.providerMessageId()).isEqualTo("provider-reference");
assertThat(found.rfcMessageId()).isEqualTo(first.message().rfcMessageId());
assertThat(found.linkedRecordId()).isEqualTo("42");
assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId())).contains(found);
for(String missing : new String[] {null, "", " ", "missing", "job:", "job:2:row:email_", "provider-reference"})
{
assertThat(EmailApi.getMessageByIdempotencyKey(missing)).isEmpty();
assertThat(EmailApi.getMessageByRfcMessageId(missing)).isEmpty();
}
assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId().replace("<", "").replace(">", ""))).isEmpty();
}
/*******************************************************************************
** Invalid handoffs preserve caller keys and never create an outbox row.
*******************************************************************************/
@Test
void sendOnceRefusesInvalidRequestsAndKeys() throws Exception
{
for(String key : new String[] {null, "", " ", "a".repeat(501)})
{
EmailApiRequest request = request(null);
assertThat(EmailApi.sendOnce(request, key)).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
assertThat(request.getIdempotencyKey()).isNull();
assertThat(EmailEffects.send(EmailApiMapper.request(request), key).outcome().name()).isEqualTo("REFUSED");
}
EmailApiRequest conflict = request("other");
assertThat(EmailApi.sendOnce(conflict, "effect").outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED);
assertThat(conflict.getIdempotencyKey()).isEqualTo("other");
assertThat(EmailApi.sendOnce(null, "effect")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
assertThat(EmailApi.sendOnce(request(null).withSubject(null), "invalid"))
.isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
assertThat(EmailApi.sendOnce(request(null), "a".repeat(500)).outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE);
instance.setEnforcePermissionsInCoreActions(true);
QContext.getQSession().setPermissions(Set.of());
assertThat(EmailApi.sendOnce(request(null), "denied")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** Replaying a suppressed audit row remains refused with the same identity.
*******************************************************************************/
@Test
void sendOnceRefusesSuppressedDuplicates() throws Exception
{
SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com")
.withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL"));
EmailApiEffectResult first = EmailApi.sendOnce(request(null), "suppressed");
assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED);
assertThat(first.message().status()).isEqualTo("SUPPRESSED");
assertThat(first.message().rfcMessageId()).isNotBlank();
assertThat(EmailApi.sendOnce(request(null), "suppressed")).isEqualTo(first);
var internal = EmailEffects.send(EmailApiMapper.request(request(null)), "suppressed");
assertThat(internal.outcome().name()).isEqualTo(first.outcome().name());
assertThat(internal.message().id()).isEqualTo(first.message().id());
assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId());
assertThat(EmailApi.getMessageByIdempotencyKey("suppressed")).contains(first.message());
assertThat(EmailApi.getMessageByRfcMessageId(first.message().rfcMessageId())).contains(first.message());
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** Neither a shared-backend transaction nor a deferred callback is a handoff.
*******************************************************************************/
@Test
void sendOnceRefusesCallerTransactions() throws Exception
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
{
for(String backend : new String[] {null, "rdbms", "other"})
{
EmailApiRequest request = request(null).withTransaction(transaction, backend);
assertThat(EmailApi.sendOnce(request, "transaction")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
assertThat(request.getIdempotencyKey()).isNull();
}
transaction.commit();
}
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** Ordinary sends retain caller transaction behavior through the public mapper.
*******************************************************************************/
@Test
void ordinarySendCanRollBackWithCaller() throws Exception
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
{
assertThat(EmailApi.send(request("rollback").withTransaction(transaction, "rdbms")).status()).isEqualTo(EmailApiStatus.QUEUED);
transaction.rollback();
}
assertThat(EmailApi.getMessageByIdempotencyKey("rollback")).isEmpty();
}
/*******************************************************************************
** Uncertain storage failures propagate so dispatchers can retry or reconcile.
*******************************************************************************/
@Test
void sendOncePropagatesStorageFailure() throws Exception
{
EmailApiEffectResult first = EmailApi.sendOnce(request(null), "collision");
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id()).withValue("idempotencyKey", "changed"));
assertThatThrownBy(() -> EmailApi.sendOnce(request(null), "collision")).isInstanceOf(QException.class);
assertThat(EmailApi.getMessageByIdempotencyKey("collision")).isEmpty();
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** Plain sends need only QQQ core messaging types at the consumer boundary.
*******************************************************************************/
@Test
void coreOnlyBridgeQueuesPlainMail() throws Exception
{
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName("qbitEmail")
.withTo(new Party().withAddress("pat@example.com")).withSubject("Ready")
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Export ready")));
assertThat(H2Support.all(EmailTables.MESSAGE)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("subject")).isEqualTo("Ready");
assertThat(row.getValueString("status")).isEqualTo("QUEUED");
});
}
/*******************************************************************************
** Template fields are preserved, creation is idempotent, and edits survive.
*******************************************************************************/
@Test
void ensuresAndMapsTemplate() throws Exception
{
EmailApiTemplate template = template().withLabel("Notice").withKind("HTML").withTextBody("Text {{number}}")
.withVariables(List.of(new EmailApiVariable("number", "Number", "STRING", true, "PUBLIC")))
.withSampleVariables("{\"number\":\"42\"}").withOwnerQbit("orders").withEditPermissionName("orders.edit");
var mapped = EmailApiMapper.template(template);
for(Method getter : EmailApiTemplate.class.getDeclaredMethods())
{
if(getter.getName().startsWith("get") && !Set.of("getVariables", "getVariablesSchema", "getCategory").contains(getter.getName()))
{
assertThat(mapped.getClass().getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(getter.invoke(template));
}
}
assertThat(mapped.getCategory()).isEqualTo(EmailCategory.TRANSACTIONAL);
assertThat(mapped.getVariablesSchema()).contains("number", "Number", "STRING", "true", "PUBLIC");
assertThat(EmailApi.ensureTemplate(template)).isTrue();
assertThat(EmailApi.ensureTemplate(template.withBody("Replacement"))).isFalse();
assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("ownerQbit")).isEqualTo("orders");
assertThat(row.getValueString("editPermissionName")).isEqualTo("orders.edit");
assertThat(row.getValueBoolean("phiApproved")).isFalse();
});
assertThat(H2Support.all(EmailTables.VERSION)).singleElement().satisfies(row ->
{
assertThat(row.getValueString("bodySource")).isEqualTo("Hello {{number}}");
assertThat(row.getValueString("textSource")).isEqualTo("Text {{number}}");
assertThat(row.getValueString("status")).isEqualTo("ACTIVE");
});
assertThat(EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withVariable("number", "42")).status())
.isEqualTo(EmailApiStatus.QUEUED);
assertThat(EmailApi.ensureTemplate(template().withName("raw_schema").withVariablesSchema("[]").withBody("Body"))).isTrue();
assertThatThrownBy(() -> EmailApi.ensureTemplate(new EmailApiTemplate())).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withVariable("number", "Number", "STRING", true, "PUBLIC")
.withVariablesSchema("[]"))).hasMessageContaining("not both");
assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withName("private_notice")
.withVariable(new EmailApiVariable("number", "Number", "STRING", true, "PHI"))
.withSampleVariables("{\"number\":\"synthetic\"}"))).hasMessageContaining("PHI");
}
/*******************************************************************************
** Lookups expose only metadata and handle literal wildcard characters.
*******************************************************************************/
@Test
void looksUpMetadataAndLiteralPrefixes() throws Exception
{
EmailApi.ensureTemplate(template().withBody("Body"));
int templateId = H2Support.all(EmailTables.TEMPLATE).getFirst().getValueInteger("id");
Instant sent = Instant.parse("2030-01-02T03:04:05Z");
Instant delivered = sent.plusSeconds(5);
EmailApiResult result = EmailApi.send(request("orders:1").withLinkedRecord("orders", 9));
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", result.messageId()).withValue("templateId", templateId)
.withValue("status", "DELIVERED").withValue("providerMessageId", "provider-42").withValue("sentDate", sent).withValue("deliveredDate", delivered));
EmailApiMessage message = EmailApi.getMessage(result.messageId()).orElseThrow();
assertThat(message.id()).isEqualTo(result.messageId());
assertThat(message.status()).isEqualTo("DELIVERED");
assertThat(message.category()).isEqualTo(EmailApiCategory.TRANSACTIONAL);
assertThat(message.idempotencyKey()).isEqualTo("orders:1");
assertThat(message.rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>");
assertThat(message.providerMessageId()).isEqualTo("provider-42");
assertThat(message.templateName()).isEqualTo("notice");
assertThat(message.linkedTableName()).isEqualTo("orders");
assertThat(message.linkedRecordId()).isEqualTo("9");
assertThat(message.createDate()).isNotNull();
assertThat(message.sentDate()).isEqualTo(sent);
assertThat(message.deliveredDate()).isEqualTo(delivered);
assertThat(EmailApi.getMessage(null)).isEmpty();
assertThat(EmailApi.getMessage(Integer.MAX_VALUE)).isEmpty();
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("missing", 3)).isEmpty();
EmailApi.send(request("orders:2"));
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 1)).containsExactly(message);
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 10)).hasSize(2);
for(String prefix : List.of("literal_", "literal%", "literal\\"))
{
EmailApi.send(request("literalX" + prefix));
EmailApiResult matching = EmailApi.send(request(prefix + "match"));
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).extracting(EmailApiMessage::id).containsExactly(matching.messageId());
}
for(String prefix : new String[] {null, "", " "})
{
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).isInstanceOf(IllegalArgumentException.class);
}
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", 0)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", -1)).isInstanceOf(IllegalArgumentException.class);
assertThat(List.of(EmailApiMessage.class.getRecordComponents())).extracting(component -> component.getName())
.containsExactly("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId", "templateName", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate");
}
/*******************************************************************************
** Default selection follows configuration and excludes disabled senders.
*******************************************************************************/
@Test
void selectsProviderAndEnabledSenders() throws Exception
{
assertThat(EmailApi.isInstalled(instance)).isTrue();
assertThat(EmailApi.isInstalled(null)).isFalse();
assertThat(EmailApi.isInstalled(new QInstance())).isFalse();
assertThat(EmailApi.defaultProviderName(instance)).contains("relay");
assertThat(EmailApi.defaultProviderName(null)).isEmpty();
assertThat(EmailApi.listSenderAddresses("unknown")).isEmpty();
assertThat(EmailApi.listSenderAddresses("relay")).isEmpty();
QRecord first = SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id"))
.withValue("address", "first@example.com").withValue("displayName", "First").withValue("enabled", true).withValue("isDefault", true));
SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id"))
.withValue("address", "disabled@example.com").withValue("enabled", false));
assertThat(EmailApi.listSenderAddresses(null)).containsExactly(new EmailApiSenderAddress(first.getValueInteger("id"), "first@example.com", "First", true));
EmailApiSenderAddress address = EmailApi.listSenderAddresses("relay").getFirst();
assertThat(address.id()).isEqualTo(first.getValueInteger("id"));
assertThat(address.address()).isEqualTo("first@example.com");
assertThat(address.displayName()).isEqualTo("First");
assertThat(address.isDefault()).isTrue();
EmailQBitConfig config = EmailRuntime.config();
config.withDefaultProviderName("unknown");
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
config.withDefaultProviderName("relay");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("isDefault", false));
assertThat(EmailApi.defaultProviderName(instance)).contains("relay");
config.withDefaultProviderName(null);
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
config.withDefaultProviderName("relay");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false));
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
assertThat(EmailApi.listSenderAddresses("relay")).isEmpty();
QInstance other = H2Support.instance(H2Support.backend(), c -> c.withSecretResolver(name -> null));
assertThatThrownBy(() -> EmailApi.defaultProviderName(other)).hasMessageContaining("current QContext");
}
/*******************************************************************************
** Readiness reports each missing prerequisite without leaking resolver data.
*******************************************************************************/
@Test
void reportsReadinessAndPhiPrerequisites() throws Exception
{
assertThat(EmailApi.readiness(null, null).reasons()).containsExactly("Email qbit is not installed");
assertThat(EmailApi.readiness(null, null).failedChecks()).containsExactly(EmailApiReadinessCheck.NOT_INSTALLED);
assertThat(EmailApi.readiness(instance, null).ready()).isTrue();
EmailApiReadiness marketing = EmailApi.readiness(instance, EmailApiCategory.MARKETING);
assertThat(marketing.ready()).isFalse();
assertThat(marketing.reasons()).hasSize(3);
assertThat(String.join(";", marketing.reasons())).contains("publicBaseUrl", "QQQ_EMAIL_UNSUBSCRIBE_KEY", "postal address");
assertThat(marketing.failedChecks()).containsExactlyInAnyOrder(EmailApiReadinessCheck.PUBLIC_BASE_URL, EmailApiReadinessCheck.UNSUBSCRIBE_KEY, EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS);
assertThat(marketing.failed(EmailApiReadinessCheck.PROVIDER_MISSING)).isFalse();
EmailQBitConfig config = EmailRuntime.config();
config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "synthetic-test-key");
QRecord brand = SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default").withValue("isDefault", true));
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).singleElement().asString().contains("postal address");
SystemWrites.updateOne(EmailTables.BRAND, new QRecord().withValue("id", brand.getValue("id")).withValue("postalAddress", "1 Main St"));
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).ready()).isTrue();
assertThat(EmailApi.send(request("multi").withTo("other@example.com").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.REFUSED);
assertThat(EmailApi.send(request("marketing").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.QUEUED);
config.withPublicBaseUrl("javascript:bad");
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs a valid publicBaseUrl");
config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name ->
{
throw (new IllegalStateException("DO_NOT_EXPOSE_RESOLVER_DETAIL"));
});
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY");
EmailApiReadiness phi = EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL, true);
assertThat(phi.reasons()).hasSize(2);
assertThat(String.join(";", phi.reasons())).contains("QQQ_EMAIL_BODY_KEY", "PHI-enabled").doesNotContain("DO_NOT_EXPOSE");
assertThat(phi.failedChecks()).containsExactlyInAnyOrder(EmailApiReadinessCheck.BODY_KEY, EmailApiReadinessCheck.PHI_PROVIDER);
config.withSecretResolver(name -> " ");
assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isFalse();
config.withSecretResolver(name -> "synthetic-test-key");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id"))
.withValue("phiAllowed", true).withValue("trackOpens", false).withValue("trackClicks", false));
assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isTrue();
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false));
assertThat(EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL).reasons()).containsExactly("An enabled named or default provider is required");
}
/*******************************************************************************
** Small valid ad-hoc request.
*******************************************************************************/
private EmailApiRequest request(String key)
{
return (new EmailApiRequest().withTo("pat@example.com").withSubject("Ready").withTextBody("Body").withIdempotencyKey(key));
}
/*******************************************************************************
** Template body may be replaced by tests using a different schema.
*******************************************************************************/
private EmailApiTemplate template()
{
return (new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Ready").withBody("Hello {{number}}"));
}
}
@@ -0,0 +1,494 @@
/*
* 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.outbox;
import java.io.Serializable;
import java.sql.Connection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
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.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsTables;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectRequest;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectResult;
import com.kingsrook.qqq.backend.core.jobs.effects.JobEffectType;
import com.kingsrook.qqq.backend.core.jobs.effects.ReconcileResult;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
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.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
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 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;
/*******************************************************************************
** Real H2 outbox and jobs lookup; no transport or mocked owner lookup.
*******************************************************************************/
class EmailJobEffectTargetTest
{
private final EmailJobEffectTarget target = new EmailJobEffectTarget();
private static QSession observedSession;
/*******************************************************************************
** Install a local outbox and provider without dispatching any messages.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(config ->
{
});
H2Support.provider(25);
observedSession = null;
}
/*******************************************************************************
** Restore per-test context and hook state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
observedSession = null;
}
/*******************************************************************************
** A normal effect contains only serializable payload values.
*******************************************************************************/
private EffectRequest request(String key, Integer jobId, Map<String, Serializable> payload)
{
return (new EffectRequest(key, "welcomeEmail", JobEffectType.IDEMPOTENT_EXTERNAL, jobId, 1, "row", payload, null, 1));
}
/*******************************************************************************
** Valid ad-hoc email payload.
*******************************************************************************/
private Map<String, Serializable> payload()
{
return (new LinkedHashMap<>(Map.of("to", "person@example.com", "subject", "Welcome", "textBody", "Hello")));
}
/*******************************************************************************
** Replays acknowledge the same committed row and ignore payload identity keys.
*******************************************************************************/
@Test
void deliversOnceAndReconciles() throws Exception
{
var payload = payload();
payload.put("senderQbit", "spoofed");
payload.put("senderProcess", "spoofed");
payload.put("idempotencyKey", "spoofed");
payload.put("transaction", "ignored");
var request = request("same", null, payload);
assertThat(target.reconcile(request)).isEqualTo(ReconcileResult.notFound());
var first = target.deliver(request);
assertThat(first.outcome()).isEqualTo(EffectResult.Outcome.SENT);
assertThat(target.deliver(request)).isEqualTo(first);
var rows = H2Support.all(EmailTables.MESSAGE);
assertThat(rows).hasSize(1);
var row = rows.getFirst();
assertThat(first.providerRef()).isEqualTo(row.getValueString("id"));
assertThat(row.getValueString("senderQbit")).isEqualTo("jobs");
assertThat(row.getValueString("senderProcess")).isEqualTo("welcomeEmail");
assertThat(row.getValueString("idempotencyKey")).isEqualTo("same");
assertThat(target.reconcile(request)).isEqualTo(ReconcileResult.found(first.providerRef()));
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", row.getValueInteger("id")).withValue("status", "DEAD_LETTER"));
assertThat(target.reconcile(request)).isEqualTo(ReconcileResult.found(first.providerRef()));
}
/*******************************************************************************
** Suppression is a permanent refusal on every attempt, but reconciles by row.
*******************************************************************************/
@Test
void suppressedRecipientFails() throws Exception
{
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "person@example.com")
.withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"));
var request = request("suppressed", null, payload());
assertThat(target.deliver(request)).isEqualTo(EffectResult.failed("All recipients are suppressed"));
assertThat(target.deliver(request).outcome()).isEqualTo(EffectResult.Outcome.FAILED);
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
assertThat(target.reconcile(request).outcome()).isEqualTo(ReconcileResult.Outcome.FOUND);
}
/*******************************************************************************
** Service validation reasons reach the effect without bypassing template rules.
*******************************************************************************/
@Test
void validationRefusalRetainsReasons() throws Exception
{
var payload = payload();
payload.put("variables", new LinkedHashMap<>(Map.of("name", "Person")));
assertThat(target.deliver(request("variables", null, payload)))
.isEqualTo(EffectResult.failed("Variables require a template with a schema"));
assertThat(target.deliver(request("empty", null, null)).outcome()).isEqualTo(EffectResult.Outcome.FAILED);
assertThat(target.deliver(request(" ", null, payload())).message()).contains("valid, matching effect key");
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** A database failure remains uncertain and does not disclose raw diagnostics.
*******************************************************************************/
@Test
void storageFailureIsUnknown() throws Exception
{
var request = request("collision", null, payload());
var sent = target.deliver(request);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", Integer.valueOf(sent.providerRef())).withValue("idempotencyKey", "changed"));
var result = target.deliver(request);
assertThat(result.outcome()).isEqualTo(EffectResult.Outcome.UNKNOWN);
assertThat(result.message()).doesNotContain("person@example.com", "INSERT", "Hello");
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** Malformed supported values follow the specified safe same-key retry policy.
*******************************************************************************/
@Test
void decodingExceptionsAreUnknown() throws Exception
{
for(var invalid : List.of(Map.entry("to", 4), Map.entry("category", "invalid"), Map.entry("variables", "invalid")))
{
var payload = payload();
payload.put(invalid.getKey(), (Serializable) invalid.getValue());
assertThat(target.deliver(request("bad", null, payload)).outcome()).isEqualTo(EffectResult.Outcome.UNKNOWN);
}
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** Every documented optional value reaches the request without losing types.
*******************************************************************************/
@Test
void decodesSupportedPayload()
{
var payload = payload();
payload.put("to", new ArrayList<>(List.of("one@example.com", "two@example.com")));
payload.put("cc", "cc@example.com");
payload.put("bcc", new ArrayList<>(List.of("bcc@example.com")));
for(String key : List.of("from", "replyTo", "templateName", "markdownBody", "htmlBody", "providerName", "brandName", "linkedTableName"))
{
payload.put(key, key);
}
payload.put("fromAddressId", 7);
payload.put("locale", "fr");
payload.put("category", "SECURITY");
payload.put("sendAfter", "2026-10-14T12:00:00Z");
payload.put("linkedRecordId", 42);
payload.put("variables", new LinkedHashMap<>(Map.of("count", 3, "active", true)));
var decoded = EmailJobEffectTarget.decode(payload);
assertThat(decoded.getTo()).containsExactly("one@example.com", "two@example.com");
assertThat(decoded.getCc()).containsExactly("cc@example.com");
assertThat(decoded.getBcc()).containsExactly("bcc@example.com");
assertThat(decoded.getFrom()).isEqualTo("from");
assertThat(decoded.getFromAddressId()).isEqualTo(7);
assertThat(decoded.getReplyTo()).isEqualTo("replyTo");
assertThat(decoded.getSubject()).isEqualTo("Welcome");
assertThat(decoded.getTemplateName()).isEqualTo("templateName");
assertThat(decoded.getMarkdownBody()).isEqualTo("markdownBody");
assertThat(decoded.getHtmlBody()).isEqualTo("htmlBody");
assertThat(decoded.getTextBody()).isEqualTo("Hello");
assertThat(decoded.getProviderName()).isEqualTo("providerName");
assertThat(decoded.getBrandName()).isEqualTo("brandName");
assertThat(decoded.getLocale()).isEqualTo("fr");
assertThat(decoded.getCategory()).isEqualTo(EmailCategory.SECURITY);
assertThat(decoded.getSendAfter()).isEqualTo(Instant.parse("2026-10-14T12:00:00Z"));
assertThat(decoded.getLinkedTableName()).isEqualTo("linkedTableName");
assertThat(decoded.getLinkedRecordId()).isEqualTo("42");
assertThat(decoded.getVariables()).containsEntry("count", 3).containsEntry("active", true);
assertThat(EmailJobEffectTarget.decode(Map.of()).getLocale()).isEqualTo("en");
}
/*******************************************************************************
** Use core's jobs metadata and a real H2 jobs_job table with all its columns.
** Only the lookup table needs physical storage; no jobs worker runs here.
*******************************************************************************/
private void enableJobs() throws Exception
{
QInstance instance = QContext.getQInstance();
instance.withSupplementalMetaData(new DurableJobsMetaData());
new DurableJobsMetaDataProvider().defineAll(instance, "rdbms", null);
instance.getAuthentication().withModuleCodeReference(new QCodeReference(OwnerAuthentication.class));
QTableMetaData table = instance.getTable(DurableJobsTables.TABLE_JOB);
List<String> columns = new ArrayList<>();
for(var field : table.getFields().values())
{
String sqlType = switch(field.getType())
{
case INTEGER -> "INTEGER";
case BOOLEAN -> "BOOLEAN";
case DATE_TIME -> "TIMESTAMP";
default -> "VARCHAR(10000)";
};
columns.add("\"" + field.getName() + "\" " + sqlType);
}
try(Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) instance.getBackend("rdbms")))
{
connection.createStatement().execute("CREATE TABLE jobs_job (" + String.join(", ", columns) + ")");
}
instance.getTable(EmailTables.MESSAGE).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(ObserveOwner.class));
QContext.init(instance, new QSystemUserSession());
}
/*******************************************************************************
** Seed just the job identity and captured scope through JDBC, like a stored job.
*******************************************************************************/
private void job(String userId) throws Exception
{
try(Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms"));
var statement = connection.prepareStatement("INSERT INTO jobs_job (\"id\", \"userId\", \"credentialScopeJson\", \"clientChannel\") VALUES (1, ?, ?, ?)"))
{
statement.setString(1, userId);
statement.setString(2, "{\"tenant\":[\"allowed\"]}");
statement.setString(3, "assistant");
statement.executeUpdate();
}
}
/*******************************************************************************
** The actual enqueue observes the owner and narrowed scope, then restores system.
*******************************************************************************/
@Test
void sendsAsJobOwner() throws Exception
{
enableJobs();
job("owner");
QSession dispatcher = QContext.getQSession();
assertThat(target.deliver(request("owner", 1, payload())).outcome()).isEqualTo(EffectResult.Outcome.SENT);
assertThat(observedSession.getUser().getIdReference()).isEqualTo("owner");
assertThat(observedSession.getClientChannel()).isEqualTo("assistant");
assertThat(observedSession.getSecurityKeyValues().get("tenant")).containsExactly("allowed");
assertThat(QContext.getQSession()).isSameAs(dispatcher);
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** A missing owner fails permanently instead of sending as the system user.
*******************************************************************************/
@Test
void missingOwnerFails() throws Exception
{
enableJobs();
job(null);
assertOwnerFailure();
}
/*******************************************************************************
** Authentication refusal fails permanently.
*******************************************************************************/
@Test
void authenticationFailureFails() throws Exception
{
enableJobs();
job("denied");
assertOwnerFailure();
}
/*******************************************************************************
** Core rejects a session for someone other than the persisted job owner.
*******************************************************************************/
@Test
void wrongOwnerFails() throws Exception
{
enableJobs();
job("wrong");
assertOwnerFailure();
}
/*******************************************************************************
** A deleted job cannot fall back to the dispatcher identity.
*******************************************************************************/
@Test
void missingJobFails() throws Exception
{
enableJobs();
assertOwnerFailure();
}
/*******************************************************************************
** Refusal happens before the outbox sees the request and preserves the context.
*******************************************************************************/
private void assertOwnerFailure() throws Exception
{
QSession dispatcher = QContext.getQSession();
var result = target.deliver(request("denied", 1, payload()));
assertThat(result.outcome()).isEqualTo(EffectResult.Outcome.FAILED);
assertThat(result.message()).startsWith("The job's owner can no longer send it:");
assertThat(QContext.getQSession()).isSameAs(dispatcher);
assertThat(observedSession).isNull();
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
}
/*******************************************************************************
** A failed send also restores the system session after using the owner context.
*******************************************************************************/
@Test
void ownerContextRestoredAfterException() throws Exception
{
enableJobs();
job("owner");
var request = request("collision", 1, payload());
var sent = target.deliver(request);
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", Integer.valueOf(sent.providerRef())).withValue("idempotencyKey", "changed"));
QSession dispatcher = QContext.getQSession();
assertThat(target.deliver(request).outcome()).isEqualTo(EffectResult.Outcome.UNKNOWN);
assertThat(QContext.getQSession()).isSameAs(dispatcher);
assertThat(observedSession.getUser().getIdReference()).isEqualTo("owner");
}
/*******************************************************************************
** A failed lookup must never be interpreted as proof of no previous handoff.
*******************************************************************************/
@Test
void reconcileStorageFailurePropagates() throws Exception
{
try(Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend("rdbms")))
{
connection.createStatement().execute("ALTER TABLE email_message RENAME TO unavailable_message");
}
assertThatThrownBy(() -> target.reconcile(request("missing", null, payload()))).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QException.class);
}
/*******************************************************************************
** The helper is optional, and producing email after jobs registers automatically.
*******************************************************************************/
@Test
void registersOnlyWhenEnabled() throws Exception
{
QInstance disabled = QContext.getQInstance();
EmailJobEffectTarget.register(disabled);
assertThat(DurableJobsMetaData.of(disabled)).isNull();
QInstance enabled = new QInstance().withSupplementalMetaData(new DurableJobsMetaData());
enabled.addBackend(H2Support.backend());
new DurableJobsMetaDataProvider().defineAll(enabled, "rdbms", null);
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("rdbms")).produce(enabled);
assertThat(DurableJobsMetaData.of(enabled).getEffectTargets()).containsOnlyKeys("email");
assertThat(DurableJobsMetaData.of(enabled).getEffectTargets().get("email").getName()).isEqualTo(EmailJobEffectTarget.class.getName());
EmailJobEffectTarget.register(enabled);
assertThat(DurableJobsMetaData.of(enabled).getEffectTargets()).hasSize(1);
}
/*******************************************************************************
** Test authentication supplies current permissions and a wider live scope.
*******************************************************************************/
public static class OwnerAuthentication extends FullyAnonymousAuthenticationModule
{
/*******************************************************************************
** Simulate an active owner, revoked authentication, or mismatched identity.
*******************************************************************************/
@Override
public QSession createAutomatedSessionForUser(QInstance instance, Serializable userId) throws QAuthenticationException
{
if("denied".equals(userId))
{
throw (new QAuthenticationException("Access revoked"));
}
return (H2Support.admin().withUser(new QUser().withIdReference("wrong".equals(userId) ? "someone-else" : String.valueOf(userId)))
.withSecurityKeyValues(Map.of("tenant", List.of("allowed", "new"))));
}
}
/*******************************************************************************
** Observe the session inside the real send without adding production test seams.
*******************************************************************************/
public static class ObserveOwner implements TableCustomizerInterface
{
/*******************************************************************************
** Email messages have no built-in user field, so observe at the insert hook.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
{
observedSession = QContext.getQSession();
return (records);
}
}
}
+28
View File
@@ -239,3 +239,31 @@ 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);
ALTER TABLE email_provider_sender_address ADD CONSTRAINT fk_email_provider_sender_address_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE CASCADE;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_from_address_id FOREIGN KEY (from_address_id) REFERENCES email_provider_sender_address (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_version_id FOREIGN KEY (template_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE SET NULL;
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE RESTRICT;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_active_version_id FOREIGN KEY (active_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_approved_version_id FOREIGN KEY (approved_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
ALTER TABLE email_template_version ADD CONSTRAINT fk_email_template_version_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE RESTRICT;
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_message_id FOREIGN KEY (source_message_id) REFERENCES email_message (id) ON DELETE SET NULL;
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_event_id FOREIGN KEY (source_event_id) REFERENCES email_event (id) ON DELETE SET NULL;
ALTER TABLE email_webhook_inbox ADD CONSTRAINT fk_email_webhook_inbox_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
CREATE INDEX email_message_from_address ON email_message (from_address_id);
CREATE INDEX email_message_template ON email_message (template_id);
CREATE INDEX email_message_template_version ON email_message (template_version_id);
CREATE INDEX email_message_brand ON email_message (brand_id);
CREATE INDEX email_template_brand ON email_template (brand_id);
CREATE INDEX email_template_active_version ON email_template (active_version_id);
CREATE INDEX email_template_approved_version ON email_template (approved_version_id);
CREATE INDEX email_suppression_source_message ON email_suppression (source_message_id);
CREATE INDEX email_suppression_source_event ON email_suppression (source_event_id);
CREATE INDEX email_event_provider ON email_event (provider_id);