Compare commits

..
45 Commits
Author SHA1 Message Date
CI e59b9e6de2 Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-07 20:32:04 +00:00
james.maes 96ab97e9b6 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#49) from deps/align-gitea into develop
build / maven (push) Successful in 2m48s
maven / build (push) Successful in 2m48s
2026-11-07 17:21:36 +00:00
james.maesandClaude Opus 5.5 b2d1c57760 build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 2m40s
maven / build (push) Successful in 2m40s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.8 -> 4.1.0-RC.25
pom.xml: qqq-frontend-next: 1.0.0-RC.19 -> 1.0.0-RC.38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:02 +00:00
james.maes e3746649d1 Merge pull request 'ci: pin shared workflow to v1' (#48) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 16s
QRun-IO/ci-policy: native / native-result (push) Successful in 14s
build / maven (push) Successful in 4m36s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 4m36s
2026-11-06 16:07:15 +00:00
james.maesandClaude Opus 5.5 a13d4f3b7b ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
build / maven (push) Successful in 5m0s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
maven / build (push) Successful in 5m0s
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:09:25 +00:00
james.maes 8fac3c7181 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#47) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
build / maven (push) Successful in 3m7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 3m6s
2026-11-05 23:16:42 +00:00
CI 0407a62202 ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
build / maven (push) Successful in 3m44s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 3m44s
2026-11-05 16:25:13 -06:00
james.maesandClaude Opus 5.5 846a8814d7 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Failing after 3m22s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m40s
maven / build (push) Failing after 3m22s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Canceled after 9m17s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:05:02 +00:00
forge-recovery bd126ad045 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
Shared Java Maven CI with Docker / secrets (push) Successful in 28s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 30s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 3m3s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 47m38s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Successful in 1m43s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 17s
qrun-release / release (push) Successful in 47m8s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 2m26s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 10s
Shared QRun source version / version (push) Successful in 1m43s
2026-11-03 18:09:48 +00:00
forge-recovery 7dd863704e ci: enroll shared Gitea release candidate workflow
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 12s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 2m59s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 2m41s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
2026-11-03 16:59:42 +00:00
forge-recoveryandCodex 9da7038b42 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 11m32s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:05 +00:00
44d9ffcda8 ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI with Docker / secrets (push) Failing after 15s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h37m46s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m15s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 22m12s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:49:16 +00:00
381014fac1 ci: move the QRun release caller to the thin v2 contract
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
Shared Java Maven CI with Docker / secrets (push) Failing after 20s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h3m40s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h4m2s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 19s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:09 +00:00
forge-recoveryandCodex 3f4c825e20 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Failing after 55m19s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m15s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 16s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:11:18 +00:00
james.maesandCodex 95ff327bf8 ci: adopt managed orb 0.8 patch track (#41)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:06 -05:00
james.maesandCodex f2a4f897e0 ci: adopt shared orb 0.7.18 (#40)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:12 -05:00
james.maesandCodex de925bd4ac ci: adopt shared orb 0.7.17 (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:25 -05:00
james.maesandCodex 7b4d98fa60 ci: use the shared branch-aware CodeQL worker (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:17:55 -05:00
james.maesandCodex f93480b521 ci: adopt central orb 0.7.12 and stable gates (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:32:44 -05:00
james.maesandCodex 2bbe208421 ci: centralize rc release completion in qqq orb (#34)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:32 -05:00
james.maesandCodex bd2724b384 ci: adopt shared orb 0.7.11 (#33)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:55:45 -05:00
james.maes 64cb2448cf Merge pull request #31 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 18:57:24 -05:00
james.maesandCodex 2fc7853ec1 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:52:13 -05:00
james.maes faad539c60 Merge pull request #30 from QRun-IO/feature/crm-template-owner
feat(templates): support checked CRM template handoff
2026-10-19 09:34:35 -05:00
james.maesandCodex e789f5bd83 feat(templates): support checked CRM template handoff
Keep template rendering and delivery ownership in email, with caller-scoped permissions and checked replay provenance.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 09:31:02 -05:00
james.maes 755a1e2465 Merge pull request #29 from QRun-IO/feature/readiness-says-where
fix(health): email.readiness says what is missing and where to set it
2026-10-16 11:51:33 -05:00
james.maesandClaude Opus 5.5 0c3c1564c3 fix(health): email.readiness says what is missing and where to set it
The readiness message was only "Email configuration incomplete" plus a code.
It now names each gap in plain words with the Email app path to fix it, and
notes that transactional email is unaffected by marketing-only gaps. The
failedChecks detail is unchanged; howToFix carries the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:36:58 -05:00
james.maes 68137d6fff Merge pull request #28 from QRun-IO/fix/email-health-tile-wording
fix(email): worded health tile instead of raw UNKNOWN
2026-10-16 10:20:09 -05:00
james.maesandClaude Opus 5.5 ba033f90bc fix(email): worded health tile instead of the raw UNKNOWN enum
The Email health tile showed the HealthStatus name (UP/DEGRADED/DOWN/UNKNOWN). It now shows Healthy / Degraded / Down, and "Not checked yet" when no
check has reported, with the check cadence in the context line. The drill to health_check is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:13:24 -05:00
james.maes 33b70be266 Merge pull request #26 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:53 -05:00
james.maesandClaude Opus 5.5 d11e38139c ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:34 -05:00
james.maes a9415f93d9 Merge pull request #25 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 50f31a9040 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-email.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:24:08 -05:00
james.maes 640e37f7c2 Merge pull request #23 from QRun-IO/fix/bundle-version-assertion
fix(test): compare the help bundle version with the qbit version
2026-10-15 01:14:50 -05:00
james.maesandClaude Opus 5.5 0cd1ee0c09 fix(test): assert the bundle version shape; the producer metadata version is a constant
The qbit metadata version is hardcoded (0.1.0), so it cannot be compared with
the filtered bundle version. Check that the bundle resolved a real
x.y.z[-SNAPSHOT|-RC.n] version instead of a literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:12:32 -05:00
james.maesandClaude Opus 5.5 b3e3b87899 fix(test): compare the help bundle version with the qbit version, not a SNAPSHOT literal
The producer test pinned the bundle version to 0.1.0-SNAPSHOT, so the
0.1.0-RC.7 release build failed (bundle and qbit both 0.1.0-RC.6 locally).
Compare the bundle with the produced qbit's version instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:02:41 -05:00
james.maes 5b21576a0b Merge pull request #22 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 156ca565fc fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:07:55 -05:00
james.maes 7cfafd7d2f Merge pull request #16 from QRun-IO/feature/health-checks
feat(health): register email provider and readiness checks with core central health
2026-10-14 21:27:01 -05:00
james.maesandClaude Opus 5.5 e88216de7e Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:26 -05:00
james.maes 0d326948fa Merge pull request #20 from QRun-IO/feature/help-bundle
feat(help): qbit-email help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
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.maesandClaude Opus 5.5 0122fb9fc1 docs: email health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:34 -05:00
james.maesandClaude Opus 5.5 fea8f8d445 refactor(email): home shows the central health status instead of its own provider health list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:34 -05:00
james.maesandClaude Opus 5.5 5be0e68ebb feat(health): register email provider and readiness checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:33 -05:00
37 changed files with 2545 additions and 343 deletions

No files matched your search

+10 -61
View File
@@ -1,65 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
setup:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|release/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: snapshot
filters:
branches:
only:
- develop
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release_candidate
filters:
branches:
only:
- /release\/.*/
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [ /^v\d+\.\d+\.\d+$/ ]
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release-no-integration
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+13 -2
View File
@@ -5,17 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
### Added
- Shared-template APIs for permission- and record-lock-checked variable discovery, typed rendering, and idempotent sends with the exact stored subject and template/version provenance. Checked subject results bind replay to the same recipient, category, sender and linked record.
- Optional host template read/write grants without granting provider, message, brand or PHI administration. Existing per-template edit permissions remain additional requirements.
- `CRM_LEGACY` templates preserve migrated CRM token replacement, overrides and text-before-HTML behavior inside the email renderer. Native templates retain their existing category and content policies; migration and opt-in remain host responsibilities (see `UPGRADE.md`).
### Changed
- Built on QQQ 4.1.0-RC.7 (default QQQ profile).
- Built on QQQ 4.1.0-RC.8 (default QQQ profile).
### Added
- Central `email.provider.<id>` and `email.readiness` health checks (DEPENDENCY, EMAIL, 300 seconds), using passive provider state and existing category readiness with deadline and secret-safety coverage. Standard Email health status widget links to central checks. Uses the QQQ 4.1.0-RC.8 health API.
- 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.
- Help for the Email app and every `email_*` table (`help/qbit-email.yaml`, STAND-IN core#1198): what the table is for and how its rows get created, shown when it is empty.
- 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
- The `email.readiness` health message now names each missing item in plain words and where to set it (for example "Brand postal address is not set (Email > Brand > open the default brand > Postal address)") and says transactional email is unaffected by marketing-only gaps. The `failedChecks` detail is unchanged; a new `howToFix` detail lists the same text.
- Derive the qbit metadata version from the Maven artifact version so released artifacts report their release version.
- `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
@@ -50,3 +58,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
- QBIT_EMAIL core messaging bridge, Review/Result actions, backend send process, dispatch/maintenance/retention schedules and host integration declarations.
- H2 concurrency and transaction tests, GreenMail end-to-end tests, fake SES tests, metadata/permission checks and process-runner coverage.
- Repository scaffold to the QRun qbit repository standard (Apache-2.0, CircleCI qqq-orb 0.6.8 with the `configure-package-install` pre-step, GA-only `publish_release`, the `release-rc` caller workflow).
### Removed
- The Email home's standalone Provider health listing, including per-provider failure messages, last-success details and rate-headroom queries. Passive delivery health and `email.provider.unhealthy` alerts are retained.
+38 -5
View File
@@ -13,7 +13,7 @@ qbit-object-storage is wired in.
## Install
Requires Java 21, QQQ **4.1.0-RC.7**, and a transactional RDBMS with the supplied unique
Requires Java 21, QQQ **4.1.0-RC.8**, 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.
@@ -51,7 +51,15 @@ They are insert-only, hold no secrets, skip a host without the tables, and never
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**, shown on the table screen and when it is empty (`help/qbit-email.yaml`). 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`.
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.
@@ -173,6 +181,31 @@ 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.
## Health checks
The producer registers these checks with QQQ central health monitoring:
| Name | Kind | Interval |
|---|---|---|
| `email.provider.<id>` (each enabled provider) | DEPENDENCY | 300 seconds |
| `email.readiness` | DEPENDENCY | 300 seconds |
Both use component `email`, category `EMAIL`, and a 5-second timeout. Provider
checks read passive delivery state only: HEALTHY maps to UP, UNHEALTHY to DOWN,
and missing/disabled/unknown state to UNKNOWN. The transport SPI has no
non-sending connectivity method; these checks never contact providers or send
mail. Readiness reuses `EmailApi.readiness` for every `EmailApiCategory`; any
unmet prerequisite is DEGRADED, with only enum names in `failedChecks`. Existing
passive state and `email.provider.unhealthy` alerts remain in place.
The Email home uses a standard status widget reading the central `health_component`
rollup, with a link to System Health's checks filtered to `email`. It no longer
lists provider diagnostics. The host must install central health metadata and its
schema; central reads use the viewer's health permissions.
This integration uses the core health API released in QQQ `4.1.0-RC.8`.
Verify offline with `./mvnw -o verify` (`mvnw.cmd` on Windows).
## Sending from another qbit
There are two supported routes. Both queue through the durable outbox; a successful
@@ -450,8 +483,8 @@ return 404. The legacy token covers every To/Cc/Bcc recipient, so use one mailbo
per opt-out message for individual preferences. Preferences UI remains deferred
until core #1162 ships in an RC.
The home uses viewer-scoped counts and queries, UTC hour/day charts, provider health
(admins), and 20 recent messages with masked recipients. No sample data or sampled
The home uses viewer-scoped counts and queries, UTC hour/day charts, a central
health status line, and 20 recent messages with masked recipients. No sample data or sampled
counts are used. Delivered/bounced/complained totals count recipient events and
use their occurred dates; sent/dead-letter totals count messages.
@@ -604,7 +637,7 @@ PHI canary egress, encryption, preview widgets and home data.
## Durable jobs
Built on QQQ **4.1.0-RC.7**. Enable `DurableJobsMetaData` and define the jobs tables
Built on QQQ **4.1.0-RC.8**. 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
+10
View File
@@ -1,5 +1,15 @@
# Upgrading
## Unreleased: shared CRM templates
Existing hosts and callers need no configuration change. This release adds APIs and a template kind; it does not migrate CRM rows or enable shared CRM templates automatically. The existing VARCHAR kind column needs no email schema change. Hosts with additional kind constraints must allow `CRM_LEGACY` before importing migrated rows.
- To grant CRM users template access, configure `EmailQBitConfig.withTemplatePermissionBaseName("crm.manage")`. The resulting `.read` and `.write` permissions apply to template/version data and the New Version/Activate Version actions, plus app navigation. They do not grant provider, brand, delivery, message or PHI approval access. A template's `editPermissionName` is still an additional write requirement, and record security locks still apply.
- `EmailApi.templateVariableNames` and `renderTemplate` read templates and active versions in the caller's session. Rendering requires an expected delivery category and returns typed content and provenance. Native HTML must not be interpreted as Markdown. Native templates reject category/content overrides, and PHI-approved templates require the existing protected send path. Preview content is not a substitute for the actual queued message subject or delivery envelope.
- `EmailApi.sendTemplateOnce` uses the existing outbox and suppression rules, returning the exact stored subject only for a checked template/version and matching sender, linked record, category and single-recipient envelope. Supply sender qbit/process and linked table/id; CC/BCC and multiple parsed mailboxes are unsupported. Existing metadata-only lookup APIs remain unchanged. A replay after active-version or envelope changes can be rejected: reconcile the existing effect rather than automatically retrying with a new key.
- `CRM_LEGACY` is for migrated templates owned by `crm`, without PHI approval or record bindings. It preserves the five historical CRM substitutions in a single pass, leaves unknown syntax literal, prefers text source over HTML fallback, and supports historical subject/body overrides. It permits the caller's transactional or marketing category while retaining delivery suppression and readiness checks. Native kinds keep their existing policies.
- Release the public email artifact before pinning the adopting CRM release. CRM/host adoption must explicitly opt in, migrate populated rows and references, and preserve provenance and rollback data during a documented maintenance window. Optional tenant CRM hosts must retain their legacy path until a supported tenant migration exists. Local verification artifacts such as `0.1.0-LOCAL-270` must never become public dependency pins.
## 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:
+32 -4
View File
@@ -22,7 +22,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-RC.6</revision>
<revision>0.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -35,7 +35,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -119,7 +119,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -168,7 +168,35 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-email.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-email.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -261,7 +289,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -59,12 +59,30 @@ public class EmailQBitConfig implements QBitConfig
private Integer leaseSeconds = 120;
private Integer bodyRetentionDays = 30;
private Integer messageRetentionDays = 0;
private String templatePermissionBaseName;
private String appName = "email";
private String appLabel = "Email";
private List<QFieldMetaData> securityFields = List.of();
private List<RecordSecurityLock> recordSecurityLocks = List.of();
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
/*******************************************************************************
** Optional host permission base for template reads/writes only.
*******************************************************************************/
public String getTemplatePermissionBaseName()
{
return (templatePermissionBaseName);
}
/*******************************************************************************
** Grant no provider, delivery, PHI approval or other email settings access.
*******************************************************************************/
public EmailQBitConfig withTemplatePermissionBaseName(String templatePermissionBaseName)
{
this.templatePermissionBaseName = templatePermissionBaseName;
return (this);
}
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
/*******************************************************************************
@@ -21,14 +21,17 @@
package com.kingsrook.qbits.email;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import com.kingsrook.qbits.email.app.EmailApp;
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
import com.kingsrook.qbits.email.delivery.EmailAlerts;
import com.kingsrook.qbits.email.metadata.EmailHelp;
import com.kingsrook.qbits.email.health.EmailHealthChecks;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
@@ -39,6 +42,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;
@@ -52,13 +56,41 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-email";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private EmailQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = EmailQBitProducer.class.getClassLoader().getResourceAsStream("qbit-email.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-email.properties; build with Maven to generate the artifact version");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-email.properties; build with Maven");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-email.properties", e);
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -197,7 +229,12 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
output.add(EmailApp.app(qBitConfig, tables));
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
EmailHelp.apply(qInstance, qBitConfig.getAppName());
EmailHealthChecks.register(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());
@@ -32,9 +32,11 @@ 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.permissions.EmailPermissionChecker;
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.CrmLegacyTemplateRenderer;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.template.VariableSchema;
@@ -120,6 +122,167 @@ public final class EmailApi
/*******************************************************************************
** Checked single-recipient template handoff for CRM history. Content is returned
** only after the stored envelope matches the caller-authorized template/version.
** Existing metadata-only APIs deliberately remain unchanged.
*******************************************************************************/
public static EmailApiTemplateEffectResult sendTemplateOnce(Integer templateId, EmailApiRequest request, String effectKey) throws QException
{
if(request == null || request.getTo().size() != 1 || !request.getCc().isEmpty() || !request.getBcc().isEmpty()
|| request.getSenderQbit() == null || request.getSenderProcess() == null
|| request.getLinkedTableName() == null || request.getLinkedRecordId() == null)
{
throw (new IllegalArgumentException("Checked template delivery requires one recipient, sender and linked record"));
}
List<String> recipients;
try
{
recipients = EmailService.addresses(request.getTo());
}
catch(Exception e)
{
throw (new IllegalArgumentException("Checked template delivery requires a valid recipient", e));
}
if(recipients.size() != 1)
{
throw (new IllegalArgumentException("Checked template delivery requires exactly one mailbox"));
}
request.withTo(recipients);
EmailApiRenderedTemplate selected = renderTemplate(templateId, request);
request.withTemplateName(selected.templateName());
EmailApiEffectResult effect = sendOnce(request, effectKey);
if(effect.outcome() == EmailApiEffectOutcome.REFUSED || effect.message() == null)
{
return (new EmailApiTemplateEffectResult(effect, null));
}
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", effect.message().id()).withLimit(1),
Set.of("templateId", "templateVersionId", "senderQbit", "senderProcess", "linkedTableName", "linkedRecordId", "category",
"toAddresses", "ccAddresses", "bccAddresses", "suppressedAddresses", "subject"));
if(rows.isEmpty())
{
throw (new QException("Checked template delivery is unavailable"));
}
QRecord row = rows.getFirst();
if(!java.util.Objects.equals(selected.templateId(), row.getValueInteger("templateId"))
|| !java.util.Objects.equals(selected.versionId(), row.getValueInteger("templateVersionId"))
|| !java.util.Objects.equals(request.getSenderQbit(), row.getValueString("senderQbit"))
|| !java.util.Objects.equals(request.getSenderProcess(), row.getValueString("senderProcess"))
|| !java.util.Objects.equals(request.getLinkedTableName(), row.getValueString("linkedTableName"))
|| !java.util.Objects.equals(request.getLinkedRecordId(), row.getValueString("linkedRecordId"))
|| !java.util.Objects.equals(request.getCategory().name(), row.getValueString("category"))
|| !java.util.Objects.equals(request.getTo().getFirst(), row.getValueString("toAddresses"))
|| !blank(row.getValueString("ccAddresses")) || !blank(row.getValueString("bccAddresses")) || !blank(row.getValueString("suppressedAddresses")))
{
throw (new QException("Checked template delivery envelope does not match"));
}
return (new EmailApiTemplateEffectResult(effect, row.getValueString("subject")));
}
/*******************************************************************************
** Optional empty recipient columns are equivalent.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Render as the caller: template and version reads enforce table/record locks.
** Native kinds retain their category and classification policy. No PHI export.
*******************************************************************************/
public static EmailApiRenderedTemplate renderTemplate(Integer templateId, EmailApiRequest request) throws QException
{
if(templateId == null || request == null || request.getCategory() == null)
{
throw (new IllegalArgumentException("Template and expected delivery category are required"));
}
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
if(Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
{
throw (new IllegalArgumentException("PHI-approved templates require the protected template send path"));
}
QRecord version = visibleActiveVersion(templateId, request.getLocale());
QRecord rendered;
if("CRM_LEGACY".equals(template.getValueString("kind")))
{
if(request.getCategory() != EmailApiCategory.TRANSACTIONAL && request.getCategory() != EmailApiCategory.MARKETING)
{
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
}
rendered = CrmLegacyTemplateRenderer.render(template, version, request.getVariables(), request.getSubject(), request.getMarkdownBody());
}
else
{
if(!Set.of("MARKDOWN", "HTML", "MJML").contains(template.getValueString("kind")))
{
throw (new IllegalArgumentException("Unsupported template kind"));
}
if(request.getSubject() != null || request.getMarkdownBody() != null || request.getHtmlBody() != null || request.getTextBody() != null)
{
throw (new IllegalArgumentException("Native template content cannot be overridden"));
}
var renderer = new EmailTemplateRenderer(template, version, EmailApiMapper.request(request));
rendered = renderer.renderContent("render", "");
}
return (new EmailApiRenderedTemplate(templateId, version.getValueInteger("id"), template.getValueString("name"), template.getValueString("kind"), rendered.getValueString("subject"),
rendered.getValueString("markdownBody"), rendered.getValueString("htmlBody"), rendered.getValueString("textBody")));
}
/*******************************************************************************
** Declare inputs without exposing content; CRM only computes values in use.
*******************************************************************************/
public static Set<String> templateVariableNames(Integer templateId) throws QException
{
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
QRecord version = visibleActiveVersion(templateId, "en");
if("CRM_LEGACY".equals(template.getValueString("kind")))
{
String source = String.valueOf(version.getValue("subject")) + version.getValue("bodySource") + version.getValue("textSource");
return (Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
.filter(name -> source.contains("{{" + name + "}}")).collect(java.util.stream.Collectors.toUnmodifiableSet()));
}
return (new VariableSchema(version.getValueString("variablesSchema")).entries().keySet());
}
/*******************************************************************************
** Read only versions visible to the caller, preferring locale then English.
*******************************************************************************/
private static QRecord visibleActiveVersion(Integer templateId, String requestedLocale) throws QException
{
String locale = requestedLocale == null ? "en" : requestedLocale;
QueryInput versionQuery = new QueryInput(EmailTables.VERSION).withShouldFetchHeavyFields(true)
.withFilter(EmailStore.equal("templateId", templateId).withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
new EmailPermissionChecker().checkPermissionsThrowing(versionQuery, QContext.getQInstance().getTable(EmailTables.VERSION));
List<QRecord> versions = new QueryAction().execute(versionQuery).getRecords();
QRecord version = versions.stream().filter(row -> locale.equals(row.getValueString("locale"))).findFirst()
.orElseGet(() -> versions.stream().filter(row -> "en".equals(row.getValueString("locale"))).findFirst().orElse(null));
if(version == null)
{
throw (new IllegalArgumentException("Template has no visible active version"));
}
return (version);
}
/*******************************************************************************
** Ordinary query actions deliberately avoid the email system-write scope.
*******************************************************************************/
private static QRecord visibleTemplateRecord(String table, QQueryFilter filter) throws QException
{
QueryInput query = new QueryInput(table).withShouldFetchHeavyFields(true).withFilter(filter);
new EmailPermissionChecker().checkPermissionsThrowing(query, QContext.getQInstance().getTable(table));
List<QRecord> rows = new QueryAction().execute(query).getRecords();
if(rows.size() != 1)
{
throw (new IllegalArgumentException("Template is unavailable"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Create a validated template and active version once. Returns false when
** it already exists. Invalid definitions throw IllegalArgumentException.
@@ -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;
/*******************************************************************************
** Typed content and provenance. HTML must never be reinterpreted as Markdown.
*******************************************************************************/
public record EmailApiRenderedTemplate(Integer templateId, Integer versionId, String templateName, String kind, String subject,
String markdownBody, String htmlBody, String textBody)
{
}
@@ -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;
/*******************************************************************************
** Exact stored subject for a checked template envelope, never an arbitrary lookup.
*******************************************************************************/
public record EmailApiTemplateEffectResult(EmailApiEffectResult effect, String subject)
{
}
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.email.app;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.health.EmailHealthWidget;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -64,6 +65,7 @@ public final class EmailApp
{
return (List.of(
widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class),
widget("emailHealth", "Email health", WidgetType.STATISTICS.getType(), EmailHealthWidget.class),
widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class)));
}
@@ -80,7 +82,7 @@ public final class EmailApp
.withLabel(config.getAppLabel())
.withIcon(new QIcon("mail"))
.withPermissionRules(EmailTables.permissionRules())
.withWidgets(List.of(HOME_WIDGET));
.withWidgets(List.of("emailHealth", HOME_WIDGET));
for(String name : EmailTables.MAIN)
{
if(tables.containsKey(name))
@@ -89,26 +89,13 @@ public final class EmailHome
List<QRecord> messages = query(EmailTables.MESSAGE, new QQueryFilter().withOrderBy(new QFilterOrderBy("createDate", false))
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(20), List.of("id", "createDate", "toAddresses", "subject", "phiApproved", "status"));
boolean admin = EmailPermissionChecker.has(EmailPermissions.ADMIN);
List<QRecord> providers = admin ? query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)),
List.of("id", "label", "name", "enabled", "healthStatus", "lastSuccessDate", "lastFailureMessage", "maxPerMinute")) : List.of();
for(QRecord provider : providers)
{
Integer limit = provider.getValueInteger("maxPerMinute");
if(limit != null)
{
int used = count(EmailTables.ATTEMPT, since("startDate", now.minusSeconds(60))
.withCriteria("startDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withCriteria("providerId", QCriteriaOperator.EQUALS, provider.getValue("id")));
provider.setValue("headroom", Math.max(0, limit - used));
}
}
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)));
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), messages, suppressions(), setup)));
}
/*******************************************************************************
@@ -42,8 +42,8 @@ public final class EmailHomeView
private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box;";
private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;";
private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);";
private static final String FAIL = "#d64545";
private static final String OK = "#2e9e5b";
private static final String FAIL = "#d64545";
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
@@ -60,9 +60,9 @@ 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)
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> messages, ChartData suppressions)
{
return (render(totals, hourly, providers, admin, messages, suppressions, List.of()));
return (render(totals, hourly, messages, suppressions, List.of()));
}
@@ -70,7 +70,7 @@ public final class EmailHomeView
/*******************************************************************************
** 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)
public static String render(List<Integer> totals, ChartData hourly, 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);
@@ -85,7 +85,6 @@ public final class EmailHomeView
}
html.append("</div>");
bars(html, "Sends per hour, last 24 hours", hourly);
providers(html, providers, admin);
messages(html, messages);
bars(html, "Suppressions added, last 30 days", suppressions);
return (html.append("</div>").toString());
@@ -189,32 +188,6 @@ public final class EmailHomeView
/*******************************************************************************
** Provider health is admin-only; disabled providers never appear healthy.
*******************************************************************************/
private static void providers(StringBuilder html, List<QRecord> providers, boolean admin)
{
section(html, "Provider health");
if(!admin || providers.isEmpty())
{
html.append("<p style=\"margin:0;opacity:.75\">").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("</p>");
}
for(QRecord provider : providers)
{
String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled"
: "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown";
String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080";
html.append("<div style=\"").append(ROW).append("\">")
.append(cell("Provider", "<strong>" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "</strong><br><span style=\"color:" + color + "\">" + status + "</span>"))
.append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate")))))
.append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None"))))
.append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("</div>");
}
html.append("</section>");
}
/*******************************************************************************
** The whole row is a link. Labelled columns stack as cards on narrow screens.
*******************************************************************************/
@@ -101,7 +101,7 @@ public final class EmailSetupStatus
}
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") + ".",
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing. See System Health for its status.",
"Check its settings and secret variables, then Send Test Email."));
}
else
@@ -0,0 +1,223 @@
/*
* 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.health;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
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.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.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
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;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** Central health adapters reuse readiness and passive delivery state; never send.
*******************************************************************************/
public class EmailHealthChecks implements HealthCheckSupplier
{
/*******************************************************************************
** Register without querying data during metadata production.
*******************************************************************************/
public static void register(QInstance instance)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
if(registry.getChecks().stream().noneMatch(check -> "email.readiness".equals(check.getName())))
{
registry.withCheck(definition("email.readiness", EmailHealthChecks::readiness));
}
if(registry.getSuppliers().stream().noneMatch(supplier -> "email.provider".equals(supplier.getNamePrefix())))
{
registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("email.provider").withComponent("email")
.withSupplier(new QCodeReference(EmailHealthChecks.class)));
}
}
/*******************************************************************************
** Provider primary keys are integers, so their names are already lowercase.
** Capture only identity; each execution rereads current state.
*******************************************************************************/
@Override
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
{
List<HealthCheckMetaData> checks = new ArrayList<>();
for(QRecord provider : providers(EmailStore.equal("enabled", true), Set.of("id")))
{
Integer id = provider.getValueInteger("id");
checks.add(definition("email.provider." + id, input -> provider(id, input)));
}
return (checks);
}
/*******************************************************************************
** Bound execution cooperatively around existing reads. Core also enforces
** the hard timeout. Exception text may contain PHI or secrets: omit it.
*******************************************************************************/
private static HealthCheckMetaData definition(String name, HealthCheck check)
{
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("email")
.withCategory(HealthCategory.EMAIL).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(EmailHealthWidget.CHECK_INTERVAL_MINUTES * 60).withTimeoutMs(5000).withCheckInstance(input ->
{
try
{
requireTime(input);
HealthCheckOutcome outcome = check.run(input);
requireTime(input);
return (outcome);
}
catch(Exception e)
{
return (HealthCheckOutcome.down("Email health check failed: " + e.getClass().getSimpleName()));
}
}));
}
/*******************************************************************************
** Aggregate all category preflights, exposing enum names instead of reasons.
*******************************************************************************/
private static HealthCheckOutcome readiness(HealthCheckInput input) throws QException
{
Set<EmailApiReadinessCheck> failed = EnumSet.noneOf(EmailApiReadinessCheck.class);
boolean ready = true;
for(EmailApiCategory category : EmailApiCategory.values())
{
requireTime(input);
var readiness = EmailApi.readiness(input.getQInstance(), category);
ready &= readiness.ready();
failed.addAll(readiness.failedChecks());
}
if(ready)
{
return (HealthCheckOutcome.up("Email configuration ready").withDetails(new HealthDetails().withValue("failedChecks", "")));
}
var config = EmailRuntime.config(input.getQInstance());
String app = config == null || config.getAppLabel() == null ? "Email" : config.getAppLabel();
List<String> fixes = failed.stream().map(check -> fix(check, app)).toList();
String message = "Email configuration incomplete: " + String.join(" ", fixes);
String details = String.join("\n", fixes);
return (HealthCheckOutcome.degraded(message.length() > 500 ? message.substring(0, 500) : message)
.withDetails(new HealthDetails().withValue("failedChecks", failed.stream().map(Enum::name).collect(Collectors.joining(",")))
.withValue("howToFix", details.length() > 4000 ? details.substring(0, 4000) : details)));
}
/*******************************************************************************
** Plain-words statement of one missing prerequisite and where to set it.
*******************************************************************************/
private static String fix(EmailApiReadinessCheck check, String app)
{
return (switch(check)
{
case NOT_INSTALLED -> "The Email qbit is not installed.";
case PROVIDER_MISSING -> "No enabled default email provider (" + app + " > Provider: add one and tick Enabled and Is Default).";
case PROVIDER_INVALID -> "The default email provider settings are invalid (" + app + " > Provider: open the default provider and correct it).";
case PHI_PROVIDER -> "PHI email needs a PHI-enabled provider with open and click tracking off (" + app + " > Provider).";
case PUBLIC_BASE_URL -> "Public base URL is not set (the publicBaseUrl setting in the Email qbit config). Needed for marketing unsubscribe links; transactional email is unaffected.";
case UNSUBSCRIBE_KEY -> "The QQQ_EMAIL_UNSUBSCRIBE_KEY secret is not set (deployment environment variable). Needed for marketing unsubscribe links; transactional email is unaffected.";
case BRAND_POSTAL_ADDRESS -> "Brand postal address is not set (" + app + " > Brand > open the default brand > Postal address). Needed for marketing email footers; transactional email is unaffected.";
case BODY_KEY -> "The QQQ_EMAIL_BODY_KEY secret is not set (deployment environment variable). Needed for PHI email.";
});
}
/*******************************************************************************
** The transport SPI has no non-sending connectivity method. Passive state
** is the only signal; never read or publish lastFailureMessage.
*******************************************************************************/
private static HealthCheckOutcome provider(Integer id, HealthCheckInput input) throws QException
{
List<QRecord> rows = providers(EmailStore.equal("id", id).withLimit(1), Set.of("id", "enabled", "healthStatus", "lastSuccessDate"));
requireTime(input);
if(rows.isEmpty() || !Boolean.TRUE.equals(rows.getFirst().getValueBoolean("enabled")))
{
return (HealthCheckOutcome.unknown("Provider disabled or removed"));
}
QRecord row = rows.getFirst();
String state = row.getValueString("healthStatus");
HealthCheckOutcome outcome = "HEALTHY".equals(state) ? HealthCheckOutcome.up("Passive provider state healthy")
: "UNHEALTHY".equals(state) ? HealthCheckOutcome.down("Passive provider state unhealthy; see provider record")
: HealthCheckOutcome.unknown("Passive provider state unavailable");
HealthDetails details = new HealthDetails().withValue("providerId", id).withValue("passive", true);
Instant lastSuccess = row.getValueInstant("lastSuccessDate");
if(lastSuccess != null)
{
details.withValue("lastSuccessDate", lastSuccess);
}
return (outcome.withDetails(details));
}
/*******************************************************************************
** Select only needed fields under the qbit's established system read scope.
*******************************************************************************/
private static List<QRecord> providers(QQueryFilter filter, Set<String> fields) throws QException
{
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(EmailTables.PROVIDER).withFilter(filter)
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
}
/*******************************************************************************
** Do not start further work after the runner's deadline or cancellation.
*******************************************************************************/
private static void requireTime(HealthCheckInput input) throws QException
{
if(Thread.currentThread().isInterrupted() || (input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline())))
{
throw (new QException("Health check deadline exceeded"));
}
}
}
@@ -0,0 +1,108 @@
/*
* 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.health;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
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.health.HealthMetaDataProvider;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** One standard status line linking to central checks; no provider diagnostics.
*******************************************************************************/
public class EmailHealthWidget extends AbstractWidgetRenderer
{
public static final String NOT_CHECKED = "Not checked yet";
//////////////////////////////////////////////////////////////////////////////
// the interval of the email health checks (EmailHealthChecks), in minutes //
//////////////////////////////////////////////////////////////////////////////
static final int CHECK_INTERVAL_MINUTES = 5;
/*******************************************************************************
** Human words for a health status; unknown means no check has reported yet.
*******************************************************************************/
static String wording(String status)
{
return (switch(status)
{
case "UP" -> "Healthy";
case "DEGRADED" -> "Degraded";
case "DOWN" -> "Down";
default -> NOT_CHECKED;
});
}
/*******************************************************************************
** Read the persisted rollup as the viewer, preserving health permissions.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String status = HealthStatus.UNKNOWN.name();
boolean installed = QContext.getQInstance().getTable(HealthMetaDataProvider.COMPONENT_TABLE) != null;
boolean readable = installed && PermissionsHelper.hasTablePermission(input, HealthMetaDataProvider.COMPONENT_TABLE, TablePermissionSubType.READ);
if(readable)
{
List<QRecord> rows = new QueryAction().execute(new QueryInput(HealthMetaDataProvider.COMPONENT_TABLE)
.withFilter(EmailStore.equal("name", "email").withLimit(1)).withFieldNamesToInclude(Set.of("status"))).getRecords();
if(!rows.isEmpty())
{
String stored = rows.getFirst().getValueString("status");
for(HealthStatus candidate : HealthStatus.values())
{
if(candidate.name().equals(stored))
{
status = candidate.name();
}
}
}
}
String context = !installed ? "Email - health monitoring not configured" : readable ? "Email - System Health. Checks run every " + CHECK_INTERVAL_MINUTES + " minutes" : "Email - health access required";
StatisticsData data = new StatisticsData().withCount(wording(status)).withCountFontSize("1rem").withCountContext(context);
if(readable)
{
data.withCountURL("/app/health_check?filter=" + URLEncoder.encode(JsonUtils.toJson(EmailStore.equal("component", "email")), StandardCharsets.UTF_8));
}
return (new RenderWidgetOutput(data));
}
}
@@ -202,7 +202,8 @@ public final class EmailEnums
{
MARKDOWN,
MJML,
HTML;
HTML,
CRM_LEGACY;
/*******************************************************************************
** Stable database value.
@@ -1,130 +0,0 @@
/*
* 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.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-email.yaml (the QQQ help-bundle format) to the
** Email app and its email_* tables: what each table is for and how
** its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class EmailHelp
{
public static final String RESOURCE = "/help/qbit-email.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = EmailHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-email".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every email_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, String appName) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(appName);
JsonNode appEntry = entries.get("app:email;slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -21,6 +21,7 @@
package com.kingsrook.qbits.email.permissions;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
@@ -30,6 +31,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -58,7 +60,7 @@ public class EmailPermissionChecker implements CustomPermissionChecker
{
throw (new QPermissionDeniedException("Email messages and attempts are written only by the email qbit. Use Send Now, Resend or Cancel."));
}
if(!has(needed))
if(!has(needed) && !hasTemplateGrant(actionInput, metaData))
{
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
}
@@ -66,6 +68,36 @@ public class EmailPermissionChecker implements CustomPermissionChecker
/*******************************************************************************
** Additional host permissions are restricted to template data and lifecycle.
*******************************************************************************/
private static boolean hasTemplateGrant(AbstractActionInput input, MetaDataWithPermissionRules metaData)
{
if(metaData instanceof QTableMetaData table
&& (EmailTables.TEMPLATE.equals(table.getName()) || EmailTables.VERSION.equals(table.getName())))
{
return (hasTemplatePermission(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput));
}
if(metaData instanceof QAppMetaData app && EmailRuntime.config() != null && EmailRuntime.config().getAppName().equals(app.getName()))
{
return (hasTemplatePermission(false));
}
return (metaData instanceof QProcessMetaData process
&& (EmailProcesses.NEW_VERSION.equals(process.getName()) || EmailProcesses.ACTIVATE_VERSION.equals(process.getName()))
&& hasTemplatePermission(true));
}
/*******************************************************************************
** Check only the configured read/write grant, without implying email.manage.
*******************************************************************************/
public static boolean hasTemplatePermission(boolean write)
{
var config = EmailRuntime.config();
String base = config == null ? null : config.getTemplatePermissionBaseName();
QSession session = QContext.getQSession();
return (base != null && !base.isBlank() && session != null && session.hasPermission(base + (write ? ".write" : ".read")));
}
/*******************************************************************************
** The permission an action needs (null: nobody may do it directly).
*******************************************************************************/
@@ -0,0 +1,90 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Compatibility for migrated CRM content. Customer values are never programs.
*******************************************************************************/
public final class CrmLegacyTemplateRenderer
{
private static final Pattern TOKENS = Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CrmLegacyTemplateRenderer()
{
}
/*******************************************************************************
** Preserve the original subject/body override and text-before-HTML behavior.
*******************************************************************************/
public static QRecord render(QRecord template, QRecord version, Map<String, Serializable> values, String subjectOverride, String bodyOverride)
{
if(!"crm".equals(template.getValueString("ownerQbit")) || Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
{
throw (new IllegalArgumentException("Legacy CRM content requires CRM ownership and cannot be PHI approved"));
}
String subject = subjectOverride == null || subjectOverride.isBlank() ? version.getValueString("subject") : subjectOverride;
String body = bodyOverride == null || bodyOverride.isBlank() ? version.getValueString("textSource") : bodyOverride;
if(body == null)
{
body = version.getValueString("bodySource");
}
if(subject == null || subject.isBlank() || body == null || body.isBlank())
{
throw (new IllegalArgumentException("Email subject and body are required"));
}
subject = replace(subject, values);
body = replace(body, values);
if(subject.contains("\r") || subject.contains("\n") || (long) subject.getBytes(StandardCharsets.UTF_8).length + body.getBytes(StandardCharsets.UTF_8).length > 1_048_576)
{
throw (new IllegalArgumentException("Invalid or oversized template output"));
}
return (new QRecord().withValue("subject", subject).withValue("markdownBody", body)
.withValue("templateId", template.getValue("id")).withValue("templateVersionId", version.getValue("id")));
}
/*******************************************************************************
** Replace only tokens in the original source, retaining unknown literals.
*******************************************************************************/
private static String replace(String source, Map<String, Serializable> values)
{
Matcher matcher = TOKENS.matcher(source);
StringBuilder result = new StringBuilder();
while(matcher.find())
{
Serializable value = values.get(matcher.group(1));
matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString()));
}
matcher.appendTail(result);
return (result.toString());
}
}
@@ -275,9 +275,9 @@ public final class EmailTemplateDefinition
{
reasons.add("Category is required");
}
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML").contains(kind))
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML", "CRM_LEGACY").contains(kind))
{
reasons.add("Kind must be MARKDOWN, MJML or HTML");
reasons.add("Kind must be MARKDOWN, MJML, HTML or CRM_LEGACY");
}
if(subject == null || subject.isBlank())
{
@@ -57,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public final class EmailTemplateRenderer
{
private final EmailRequest request;
private final QRecord template;
private final QRecord version;
private final QRecord brand;
@@ -98,6 +99,7 @@ public final class EmailTemplateRenderer
*******************************************************************************/
public EmailTemplateRenderer(QRecord template, QRecord version, EmailRequest request) throws QException
{
this.request = request;
this.template = template;
this.version = version;
approved = Boolean.TRUE.equals(template.getValueBoolean("phiApproved")) && version.getValue("id") != null
@@ -117,6 +119,21 @@ public final class EmailTemplateRenderer
EmailBrands.validate(brand);
schema = new VariableSchema(version.getValueString("variablesSchema"));
records = new LinkedHashMap<>(request.getRecordVariables());
if("CRM_LEGACY".equals(template.getValueString("kind")))
{
if(!records.isEmpty())
{
throw (new IllegalArgumentException("Legacy CRM variables must be supplied from caller-visible records"));
}
values = new LinkedHashMap<>(request.getVariables());
category = request.getCategory() == null ? EmailCategory.valueOf(template.getValueString("category")) : request.getCategory();
if(category != EmailCategory.TRANSACTIONAL && category != EmailCategory.MARKETING)
{
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
}
CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
return;
}
java.util.Set<String> references = new java.util.LinkedHashSet<>();
for(String field : List.of("subject", "preheader", "bodySource", "textSource", "compiledHtml"))
{
@@ -287,6 +304,40 @@ public final class EmailTemplateRenderer
*******************************************************************************/
public QRecord render(String messageId, String unsubscribe, boolean encrypt)
{
return (render(messageId, unsubscribe, encrypt, true));
}
/*******************************************************************************
** Render typed content for an existing transport, which adds its own layout.
*******************************************************************************/
public QRecord renderContent(String messageId, String unsubscribe)
{
return (render(messageId, unsubscribe, false, false));
}
/*******************************************************************************
** Share parsing, classification and output limits across both consumers.
*******************************************************************************/
private QRecord render(String messageId, String unsubscribe, boolean encrypt, boolean decorate)
{
if("CRM_LEGACY".equals(template.getValueString("kind")))
{
QRecord rendered = CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
String html = EmailMarkdown.render(rendered.getValueString("markdownBody"), brand);
if(decorate)
{
html = EmailLayout.render(html, null, brand, category, unsubscribe);
}
String text = HtmlToText.convert(html);
if((long) rendered.getValueString("subject").getBytes(java.nio.charset.StandardCharsets.UTF_8).length
+ html.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
return (rendered.withValue("htmlBody", html).withValue("textBody", text)
.withValue("variablesSnapshot", "{}").withValue("brandId", brand.getValue("id"))
.withValue("locale", version.getValue("locale")).withValue("category", category.name()).withValue("phiApproved", false));
}
Map<String, Serializable> context = new LinkedHashMap<>(values);
for(String field : List.of("label", "name", "siteUrl", "logoUrl", "logoDarkUrl", "logoAlt", "postalAddress", "primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "darkPrimaryColor", "darkButtonTextColor", "footerMarkdown"))
{
@@ -310,8 +361,12 @@ public final class EmailTemplateRenderer
}
String body = engine.parse(source, schema).render(context, "MARKDOWN".equals(kind) ? EmailTemplateEngine.Context.MARKDOWN : EmailTemplateEngine.Context.HTML, budget, warnings);
String preheader = engine.parse(version.getValueString("preheader"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
String html = "MARKDOWN".equals(kind) ? EmailLayout.render(EmailMarkdown.render(body, brand), preheader, brand, category, unsubscribe)
: EmailLayout.appendFooter(body, brand, category, unsubscribe);
String html = "MARKDOWN".equals(kind) ? EmailMarkdown.render(body, brand) : body;
if(decorate)
{
html = "MARKDOWN".equals(kind) ? EmailLayout.render(html, preheader, brand, category, unsubscribe)
: EmailLayout.appendFooter(html, brand, category, unsubscribe);
}
if(approved)
{
html = restrictImages(html);
@@ -59,7 +59,8 @@ public final class EmailVersions
{
return;
}
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE))
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE)
&& (approval || !EmailPermissionChecker.hasTemplatePermission(true)))
{
throw (new IllegalArgumentException(approval ? "PHI approval needs email.admin" : "Editing templates needs email.manage"));
}
@@ -125,9 +126,9 @@ public final class EmailVersions
QRecord template = lock(version.getValueInteger("templateId"), tx);
requireEdit(template, true);
version = requiredVersion(versionId, tx);
if("RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
if("CRM_LEGACY".equals(template.getValueString("kind")) || "RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
{
throw (new IllegalArgumentException("Retired versions and MARKETING templates cannot be approved for PHI"));
throw (new IllegalArgumentException("Legacy CRM templates, retired versions and MARKETING templates cannot be approved for PHI"));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id"))
.withValue("phiApproved", true).withValue("approvedVersionId", versionId).withValue("phiApprovedBy", actor())
@@ -176,6 +177,11 @@ public final class EmailVersions
public static void validate(QRecord template, QRecord version) throws QException
{
requireEdit(template, false);
if("CRM_LEGACY".equals(template.getValueString("kind")))
{
CrmLegacyTemplateRenderer.render(template, version, java.util.Map.of(), null, null);
return;
}
if("MJML".equals(template.getValueString("kind")))
{
CliMjmlCompiler.rejectIncludes(version.getValueString("bodySource"));
+697 -50
View File
@@ -1,63 +1,710 @@
bundle: qbit-email
version: 1
version: ${project.version}
layer: QBIT
# Help for the Email app and every email_* table, in the QQQ help-bundle format
# (qqq#1198). Each table entry says what the table is for and how its rows get
# created, so an empty table explains itself. EmailHelp applies it until the core
# HelpBundles registry ships in a QQQ release (a stand-in until core #1198 ships).
# Summaries stay under 120 characters (EmailTablesGuardrailTest).
entries:
- key: app:email;slot:help
summary: "One email service: a durable outbox, providers, templates with branding, delivery tracking and suppression."
content: |
The Setup card on the home (email admins) lists every part of Email with its status and the next step. Nothing sends until an enabled default provider exists: that is your mail server or service, so the qbit cannot invent it. A new installation starts with a neutral brand to copy, and the secure-message template appears the first time the home opens.
- 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.
- key: table:email_provider;slot:help
summary: "A way to send mail: SMTP, SES, SendGrid, Mailgun or Postmark, with limits and the names of its secret variables."
content: |
**How rows get created:** an administrator adds one with New: pick the kind, the host or region, a from address, and the NAMES of the environment variables that hold the credentials (the secrets themselves are never stored). Turn on Default so sends use it. Run Send Test Email on it to check the connection, then watch its health. This is your own connection, so it starts empty. Empty means nothing can be sent yet.
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.
- key: table:email_provider_sender_address;slot:help
summary: "An address a provider may send from, with a display name. One can be the default for that provider."
content: |
**How rows get created:** add one from a provider's Sender Addresses list when the provider needs more than its single from address (several mailboxes or brands). A request may name one; otherwise the default is used. Empty is normal: the provider's own from address is used.
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.
- key: table:email_message;slot:help
summary: "The outbox: every email the system queued, with its status, attempts, bounce and complaint dates."
content: |
**How rows get created:** never by hand. Every send, from any qbit or process, is queued here first (EmailApi.send, the Send Email process, or a messaging provider), then the dispatcher sends it, retries failures and records the result. Actions: Send Now, Resend and Cancel. Empty means nothing has been sent from this system yet.
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.
- key: table:email_message_attempt;slot:help
summary: "One try to hand a message to a provider: outcome, timing and error. Read-only."
content: |
**How rows get created:** written by the dispatcher each time it tries to send a message. Used for provider rate limits and the hourly chart on the home. Empty means no send has been attempted.
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.
- key: table:email_template;slot:help
summary: "A reusable email: a name, category, brand and the versions that hold its subject and body."
content: |
**How rows get created:** the qbit creates the secure-message template for you; other qbits register theirs (EmailApi.ensureTemplate) and are named in Owner Qbit; or an administrator adds one with New, then writes its first version and runs Activate Version. Only one version is active at a time. Approve For PHI is a separate, explicit step per version.
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.
- key: table:email_template_version;slot:help
summary: "An immutable version of a template: subject, body, variables and locale. Activate one to use it."
content: |
**How rows get created:** use New Version on a template, edit the draft (Markdown or MJML body, the variables it accepts, sample values), then Activate Version, which checks the template renders. Activated versions never change. Empty means no template has a version yet.
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.
- key: table:email_brand;slot:help
summary: "Look and footer of branded emails: logo, colors for light and dark, footer text and postal address."
content: |
**How rows get created:** the qbit seeds a neutral, high-contrast brand named neutral (not the default, so it changes nothing until you choose it). Copy it for your own: set the logo (https), colors (contrast is checked), footer and postal address, then tick Default Brand to use it for everything that names no brand. Marketing email needs a default brand with a postal address. Without any default the plain neutral look is used.
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.
- key: table:email_suppression;slot:help
summary: "Addresses that must not receive mail: hard bounces, complaints, unsubscribes and manual blocks."
content: |
**How rows get created:** automatically when a provider reports a hard bounce or complaint through its webhook, when someone unsubscribes, or by an administrator with New (a manual block, for one category or all). Sends to these addresses are skipped and noted on the message. Empty means nobody has bounced, complained or unsubscribed yet.
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.
- key: table:email_webhook_inbox;slot:help
summary: "Provider webhook calls waiting to be processed, kept so none is lost: status, attempts and errors."
content: |
**How rows get created:** written when a verified provider webhook (delivery, bounce, complaint) arrives at the qbit's webhook path (/qqq/hooks/email by default), then processed in the background. Reprocess retries a failed one. Empty means no provider has called back; set each provider's webhook secret variable and point the provider at the webhook URL.
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.
- key: table:email_event;slot:help
summary: "Delivery tracking events per message: delivered, bounced, complained, opened, clicked."
content: |
**How rows get created:** written by the webhook processor from provider callbacks. Read-only; each message's timeline is built from these. Empty means no provider has reported anything yet.
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:emailHealth;slot:label
summary: 'Central Email health status, with a link to its checks in System Health.'
- 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.
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -0,0 +1,75 @@
/*
* 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.Map;
import com.kingsrook.qbits.email.template.CrmLegacyTemplateRenderer;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Populated legacy fixtures preserve behavior even when production has no rows.
*******************************************************************************/
class CrmLegacyTemplateRendererTest
{
/*******************************************************************************
** Unknown syntax and substituted values stay literal; replacements run once.
*******************************************************************************/
@Test
void literalSinglePassAndBodyPreference()
{
QRecord template = new QRecord().withValue("id", 7).withValue("ownerQbit", "crm");
QRecord version = new QRecord().withValue("id", 9).withValue("subject", "Hi {{contact.firstName}}")
.withValue("bodySource", "<p>html</p>")
.withValue("textSource", "{{contact.lastName}} {{company.name}} {{owner.name}} {{unsubscribeUrl}} {{unknown}} {{#if x}}");
QRecord rendered = CrmLegacyTemplateRenderer.render(template, version,
Map.of("contact.firstName", "{{contact.lastName}}", "contact.lastName", "$1\\value", "company.name", "A&B", "owner.name", "42", "unsubscribeUrl", "https://example.test/u"), null, null);
assertThat(rendered.getValueString("subject")).isEqualTo("Hi {{contact.lastName}}");
assertThat(rendered.getValueString("markdownBody")).isEqualTo("$1\\value A&B 42 https://example.test/u {{unknown}} {{#if x}}");
assertThat(rendered.getValueInteger("templateId")).isEqualTo(7);
assertThat(rendered.getValueInteger("templateVersionId")).isEqualTo(9);
version.setValue("textSource", null);
assertThat(CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null).getValueString("markdownBody")).isEqualTo("<p>html</p>");
assertThat(CrmLegacyTemplateRenderer.render(template, version, Map.of(), "override", "body").getValueString("subject")).isEqualTo("override");
version.setValue("textSource", "");
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("required");
}
/*******************************************************************************
** Compatibility never grants PHI export or accepts header injection.
*******************************************************************************/
@Test
void rejectsProtectedOrInvalidContent()
{
QRecord template = new QRecord().withValue("ownerQbit", "crm").withValue("phiApproved", true);
QRecord version = new QRecord().withValue("subject", "subject").withValue("bodySource", "body");
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("cannot be PHI");
template.setValue("phiApproved", false);
template.setValue("ownerQbit", "other");
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("CRM ownership");
template.setValue("ownerQbit", "crm");
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), "subject\nheader", null)).hasMessageContaining("Invalid");
}
}
@@ -108,8 +108,8 @@ 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("Setup", "Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Recent messages", "Suppressions added, last 30 days");
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHealth", "emailHome");
}
/*******************************************************************************
@@ -148,7 +148,7 @@ class EmailHomePolishTest
** Twenty newest linked rows wrap long text, mask addresses, and hide PHI.
*******************************************************************************/
@Test
void recentRowsAndProviderHealth() throws Exception
void recentRowsExcludeProviderDiagnostics() throws Exception
{
for(int i = 0; i < 21; i++)
{
@@ -166,13 +166,12 @@ class EmailHomePolishTest
assertThat(rows.first().attr("href")).isEqualTo("/app/email_message/21");
assertThat(rows.last().attr("href")).isEqualTo("/app/email_message/2");
assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry");
assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "&lt;broken&gt;", "min-width:0", "overflow-wrap:anywhere")
.doesNotContain("private-canary", "Person &lt;", "RETRY_WAIT", "2026-10-14T");
assertThat(document.select("section").get(2).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
assertThat(html).contains("Secure message", "p***@", "min-width:0", "overflow-wrap:anywhere")
.doesNotContain("private-canary", "Person &lt;", "RETRY_WAIT", "2026-10-14T", "Provider health", "&lt;broken&gt;", "Per-minute headroom");
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null));
assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit");
assertThat(EmailHome.overview().getHtml()).doesNotContain("Disabled", "No limit", "Provider health");
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(EmailHome.overview().getHtml()).contains("Provider health needs email.admin").doesNotContain("&lt;broken&gt;");
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "&lt;broken&gt;");
}
/*******************************************************************************
@@ -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;
@@ -47,8 +48,33 @@ class EmailQBitProducerTest
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getNamespace()).isNull();
assertThat(producer.getQBitConfig().getBackendName()).isEqualTo("memory");
String artifactVersion = System.getProperty("qbit.artifactVersion");
assertThat(artifactVersion).isNotBlank();
assertThat(EmailQBitProducer.VERSION).isEqualTo(artifactVersion);
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion);
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(artifactVersion).isEqualTo(EmailQBitProducer.VERSION);
});
}
/*******************************************************************************
** 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"));
}
/*******************************************************************************
@@ -28,11 +28,11 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.email.metadata.EmailHelp;
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.QHelpContent;
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;
@@ -128,16 +128,17 @@ class EmailTablesGuardrailTest
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
assertThat(EmailHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
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)
{
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(EmailHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
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(instance.getApp("email").getHelpContent()).containsKey(EmailHelp.SLOT);
assertThat(EmailHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
assertThat(entries).containsKey(new HelpBundles.EntryKey("app:email;slot:help", "ALL_SCREENS"));
}
@@ -221,7 +221,7 @@ class EmailTemplatesTest
@Test
void previewAndHome() throws Exception
{
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("<style", "overflow:auto");
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Setup", "Relay").doesNotContain("Provider health").doesNotContain("<style", "overflow:auto");
assertThat(EmailHome.hourly().getSublabel()).contains("No completed attempts");
assertThat(EmailHome.suppressions().getSublabel()).contains("No new suppressions");
QRecord version = draft("preview", "HTML", "[]", "{}", "<html><body><p>Preview text</p><script>evil()</script><img src=x onerror=evil()></body></html>");
@@ -233,7 +233,7 @@ class EmailTemplatesTest
EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person <person@example.com>"));
assertThat(EmailHome.overview().getHtml()).contains("p***@example.com", "/app/email_message/");
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(EmailHome.overview().getHtml()).contains("needs email.admin");
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "needs email.admin");
}
/*******************************************************************************
@@ -0,0 +1,198 @@
/*
* 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.Set;
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.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailVersions;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
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;
/*******************************************************************************
** Host template grants never imply email administration or bypass record locks.
*******************************************************************************/
class SharedTemplateAccessTest
{
/*******************************************************************************
** Use real QQQ permission and database actions with explicit host opt-in.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
H2Support.setup(config -> config.withTemplatePermissionBaseName("crm.manage"));
}
/*******************************************************************************
** Clear request-local state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Template grants do not expose providers, messages or PHI approval.
*******************************************************************************/
@Test
void narrowPermissionsAndAdditionalEditGrant() throws Exception
{
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_legacy_1").withLabel("Legacy")
.withKind("CRM_LEGACY").withOwnerQbit("crm").withEditPermissionName("crm.manage.write")
.withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("Hello {{contact.firstName}}").withBody("body"));
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "crm_legacy_1")).getFirst();
Integer id = template.getValueInteger("id");
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
var request = new EmailApiRequest().withCategory(EmailApiCategory.MARKETING).withVariable("contact.firstName", "Ada");
assertThat(EmailApi.renderTemplate(id, request).subject()).isEqualTo("Hello Ada");
assertThatThrownBy(() -> EmailVersions.requireEdit(template, false)).hasMessageContaining("email.manage");
EmailPermissionChecker checker = new EmailPermissionChecker();
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new QueryInput(EmailTables.PROVIDER), QContext.getQInstance().getTable(EmailTables.PROVIDER))).hasMessageContaining("email.admin");
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new QueryInput(EmailTables.MESSAGE), QContext.getQInstance().getTable(EmailTables.MESSAGE))).hasMessageContaining("email.view");
QContext.getQSession().setPermissions(Set.of("crm.manage.read", "crm.manage.write"));
EmailVersions.requireEdit(template, false);
checker.checkPermissionsThrowing(new UpdateInput(EmailTables.TEMPLATE), QContext.getQInstance().getTable(EmailTables.TEMPLATE));
assertThatThrownBy(() -> EmailVersions.requireEdit(template, true)).hasMessageContaining("email.admin");
template.setValue("editPermissionName", "another.owner.write");
assertThatThrownBy(() -> EmailVersions.requireEdit(template, false)).hasMessageContaining("another.owner.write");
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(EmailTables.BRAND), QContext.getQInstance().getTable(EmailTables.BRAND))).hasMessageContaining("email.manage");
assertThat(EmailProcesses.permissionFor(EmailProcesses.APPROVE_PHI)).isEqualTo("email.admin");
QContext.getQSession().setPermissions(Set.of());
assertThatThrownBy(() -> EmailApi.renderTemplate(id, request)).hasMessageContaining("email.view");
}
/*******************************************************************************
** A permitted table read still cannot render another tenant's template.
*******************************************************************************/
@Test
void renderHonorsRecordLocks() throws Exception
{
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_locked").withLabel("Locked")
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("subject").withBody("body"));
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "crm_locked")).getFirst();
QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("templateAccess"));
QContext.getQInstance().getTable(EmailTables.TEMPLATE).withRecordSecurityLock(new RecordSecurityLock().withFieldName("id").withSecurityKeyType("templateAccess"));
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
assertThatThrownBy(() -> EmailApi.renderTemplate(template.getValueInteger("id"), new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL)))
.hasMessageContaining("unavailable");
}
/*******************************************************************************
** Native templates retain their category and typed HTML; approval is not exportable.
*******************************************************************************/
@Test
void nativePolicyAndTypedContent() throws Exception
{
EmailApi.ensureTemplate(new EmailApiTemplate().withName("native_html").withLabel("Native")
.withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("subject").withBody("<p>body</p>"));
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "native_html")).getFirst();
Integer id = template.getValueInteger("id");
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
var rendered = EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL));
assertThat(rendered.htmlBody()).contains("<p>body</p>");
assertThat(rendered.markdownBody()).isNull();
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.MARKETING))).hasMessageContaining("category cannot be overridden");
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("override"))).hasMessageContaining("cannot be overridden");
EmailStore.update(EmailTables.TEMPLATE, new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", id).withValue("phiApproved", true), null);
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL))).hasMessageContaining("protected template send path");
}
/*******************************************************************************
** Compatibility content still uses the real outbox and stable template provenance.
*******************************************************************************/
@Test
void legacyOutboxCategoriesAndIdempotency() throws Exception
{
H2Support.provider(25);
EmailRuntime.config().withPublicBaseUrl("https://app.example.test").withSecretResolver(name -> "synthetic-unsubscribe-key");
H2Support.insert(EmailTables.BRAND, new com.kingsrook.qqq.backend.core.model.data.QRecord()
.withValue("name", "postal").withValue("isDefault", true).withValue("postalAddress", "123 Test Street"));
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_delivery").withLabel("Delivery")
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Hi {{contact.firstName}}").withBody("<b>fallback</b>").withTextBody("Hello {{contact.firstName}}"));
for(EmailApiCategory category : java.util.List.of(EmailApiCategory.TRANSACTIONAL, EmailApiCategory.MARKETING))
{
String key = "crm:test:" + category;
var request = new EmailApiRequest().withTo("recipient@example.test").withTemplateName("crm_delivery")
.withCategory(category).withVariable("contact.firstName", "{{unknown}} ").withIdempotencyKey(key);
var first = EmailApi.sendOnce(request, key);
if(first.outcome() == com.kingsrook.qbits.email.api.EmailApiEffectOutcome.REFUSED)
{
throw (new AssertionError(EmailApi.send(request).toString()));
}
assertThat(EmailApi.sendOnce(request, key).message().id()).isEqualTo(first.message().id());
var row = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("id", first.message().id())).getFirst();
assertThat(row.getValueString("category")).isEqualTo(category.name());
assertThat(row.getValueString("subject")).isEqualTo("Hi {{unknown}} ");
assertThat(row.getValue("templateId")).isNotNull();
assertThat(row.getValue("templateVersionId")).isNotNull();
assertThat(row.getValueString("variablesSnapshot")).isEqualTo("{}");
}
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(2);
}
/*******************************************************************************
** Final subjects use the actual message ID; replay never exports another envelope.
*******************************************************************************/
@Test
void checkedSubjectAndReplayBinding() throws Exception
{
H2Support.provider(25);
EmailApi.ensureTemplate(new EmailApiTemplate().withName("native_history").withLabel("History")
.withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("Message {{message.id}}").withBody("<p>body</p>"));
Integer id = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "native_history")).getFirst().getValueInteger("id");
var request = new EmailApiRequest().withTo("recipient@example.test").withCategory(EmailApiCategory.TRANSACTIONAL)
.withSender("crm", "manual").withLinkedRecord("crm_contact", 1);
request.withTo(java.util.List.of("one@example.test,two@example.test"));
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:invalid")).hasMessageContaining("exactly one mailbox");
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
request.withTo(java.util.List.of(" recipient@example.test "));
var first = EmailApi.sendTemplateOnce(id, request, "crm:checked");
assertThat(first.subject()).isEqualTo("Message " + first.effect().message().id());
assertThat(EmailApi.sendTemplateOnce(id, request, "crm:checked").subject()).isEqualTo(first.subject());
request.withTo(java.util.List.of("other@example.test"));
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
request.withTo(java.util.List.of("recipient@example.test")).withLinkedRecord("crm_contact", 2);
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
request.withLinkedRecord("crm_contact", 1).withSender("other", "manual");
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
}
}
@@ -0,0 +1,389 @@
/*
* 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.health;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
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 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;
/*******************************************************************************
** Exercise the producer and real H2 reads without contacting any transport.
*******************************************************************************/
class EmailHealthChecksTest
{
private static final String SENTINEL = "S3NT1NEL-secret";
private QInstance instance;
private QRecord provider;
/*******************************************************************************
** All credential values are synthetic; transport calls must never occur.
*******************************************************************************/
@BeforeEach
void setup() throws Exception
{
instance = H2Support.setup(config -> config.withSecretResolver(name -> SENTINEL));
provider = H2Support.provider(25);
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id"))
.withValue("usernameEnvName", "EMAIL_SMTP_USERNAME").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD")
.withValue("security", "STARTTLS").withValue("lastFailureMessage", "IllegalStateException: " + SENTINEL + " recipient@example.test"));
EmailRuntime.config().withTransportRegistryFactory(() ->
{
throw (new AssertionError("Health checks must not create transports"));
});
}
/*******************************************************************************
** Clear the thread-local instance after each isolated database.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Production registers a static check and a dynamic supplier with stable ids.
*******************************************************************************/
@Test
void registrationAndDynamicMembership() throws Exception
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
EmailHealthChecks.register(instance);
assertThat(registry.getChecks()).extracting(HealthCheckMetaData::getName).containsExactly("email.readiness");
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
{
assertThat(supplier.getNamePrefix()).isEqualTo("email.provider");
assertThat(supplier.getComponent()).isEqualTo("email");
});
assertThat(registry.resolveAll(instance)).hasSize(2).allSatisfy(check ->
{
assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*");
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(check.getCategory()).isEqualTo(HealthCategory.EMAIL);
assertThat(check.getComponent()).isEqualTo("email");
assertThat(check.getIntervalSeconds()).isEqualTo(300);
assertThat(check.getTimeoutMs()).isEqualTo(5000);
});
HealthCheck cached = providerCheck();
update("enabled", false);
assertThat(registry.resolveAll(instance)).hasSize(1);
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
update("enabled", true);
assertThat(registry.resolveAll(instance)).hasSize(2);
SystemWrites.delete(EmailTables.PROVIDER, EmailStore.equal("id", provider.getValueInteger("id")));
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
assertThat(registry.resolveAll(instance)).hasSize(1);
}
/*******************************************************************************
** A resolved check observes later delivery transitions, with safe details only.
*******************************************************************************/
@Test
void passiveStatesAndNoSecrets() throws Exception
{
HealthCheck check = providerCheck();
update("healthStatus", "HEALTHY");
Instant success = Instant.parse("2026-10-14T12:00:00Z");
update("lastSuccessDate", success);
var outcome = run(check);
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
assertThat(outcome.getDetails().getValues()).containsEntry("passive", true).containsEntry("lastSuccessDate", success);
update("healthStatus", "UNHEALTHY");
outcome = run(check);
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(outcome.getMessage()).contains("see provider record");
for(String state : new String[] {null, SENTINEL})
{
update("healthStatus", state);
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
}
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty();
}
/*******************************************************************************
** Marketing gaps degrade the aggregate even while other categories are ready.
*******************************************************************************/
@Test
void readinessAcrossCategories() throws Exception
{
var outcome = run(readinessCheck());
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues().get("failedChecks").toString().split(","))
.containsExactly("PUBLIC_BASE_URL", "BRAND_POSTAL_ADDRESS");
assertThat(outcome.getMessage()).contains("Brand postal address is not set").contains("Brand > open the default brand > Postal address").contains("transactional email is unaffected").hasSizeLessThanOrEqualTo(500);
assertThat(outcome.getDetails().getValues().get("howToFix").toString()).contains("Public base URL is not set").contains("Brand postal address is not set");
configureMarketing();
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
update("enabled", false);
outcome = run(readinessCheck());
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_MISSING");
update("enabled", true);
update("host", null);
assertThat(run(readinessCheck()).getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_INVALID");
}
/*******************************************************************************
** Secret resolver exceptions are expected readiness gaps, never diagnostics.
*******************************************************************************/
@Test
void readinessResolverFailureIsSecretSafe() throws Exception
{
configureMarketing();
EmailRuntime.config().withSecretResolver(name ->
{
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
});
var outcome = run(readinessCheck());
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "UNSUBSCRIBE_KEY");
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
}
/*******************************************************************************
** Unexpected database/customizer errors cannot leak their exception text.
*******************************************************************************/
@Test
void queryFailuresAreSecretSafe() throws Exception
{
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
instance.getTable(EmailTables.PROVIDER).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(PoisonedQuery.class));
for(HealthCheckMetaData check : checks)
{
assertThat(run(check.getCheckInstance()).getStatus()).isEqualTo(HealthStatus.DOWN);
}
}
/*******************************************************************************
** Expired deadlines prevent reads; expiration during readiness prevents UP.
*******************************************************************************/
@Test
void respectsDeadlinesAndCancellation() throws Exception
{
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
AtomicBoolean queried = new AtomicBoolean();
EmailRuntime.config().withSecretResolver(name ->
{
queried.set(true);
return (SENTINEL);
});
for(HealthCheckMetaData check : checks)
{
var outcome = check.getCheckInstance().run(input().withDeadline(Instant.EPOCH));
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
clean(outcome);
}
assertThat(queried).isFalse();
EmailRuntime.config().withSecretResolver(name ->
{
Thread.currentThread().interrupt();
return (SENTINEL);
});
try
{
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(run(providerCheckFrom(checks)).getStatus()).isEqualTo(HealthStatus.DOWN);
}
finally
{
Thread.interrupted();
}
}
/*******************************************************************************
** The health runner has health permissions only; qbit reads use system scope.
*******************************************************************************/
@Test
void runsWithHealthOnlyPermissions() throws Exception
{
configureMarketing();
instance.setEnforcePermissionsInCoreActions(true);
QContext.getQSession().setPermissions(Set.of("health.read"));
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
}
/*******************************************************************************
** Work finishing after its deadline must not report a successful result.
*******************************************************************************/
@Test
void deadlineExpiresDuringReadiness() throws Exception
{
configureMarketing();
HealthCheckInput input = input();
EmailRuntime.config().withSecretResolver(name ->
{
input.setDeadline(Instant.EPOCH);
return (SENTINEL);
});
var outcome = readinessCheck().run(input);
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
clean(outcome);
}
/*******************************************************************************
** Minimal valid marketing prerequisites use a synthetic signing secret.
*******************************************************************************/
private void configureMarketing() throws Exception
{
EmailRuntime.config().withPublicBaseUrl("https://app.example.test");
SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default")
.withValue("isDefault", true).withValue("postalAddress", "1 Main St"));
}
/*******************************************************************************
** Reuse system mutation semantics for passive fields.
*******************************************************************************/
private void update(String field, java.io.Serializable value) throws Exception
{
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue(field, value));
}
/*******************************************************************************
** Resolve dynamic definitions through the real registry.
*******************************************************************************/
private HealthCheck providerCheck() throws Exception
{
return (providerCheckFrom(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)));
}
/*******************************************************************************
** Obtain the check for this fixture's integer primary key.
*******************************************************************************/
private HealthCheck providerCheckFrom(List<HealthCheckMetaData> checks)
{
return (checks.stream().filter(check -> check.getName().equals("email.provider." + provider.getValueInteger("id"))).findFirst().orElseThrow().getCheckInstance());
}
/*******************************************************************************
** Static readiness definition installed by the producer.
*******************************************************************************/
private HealthCheck readinessCheck()
{
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().getFirst().getCheckInstance());
}
/*******************************************************************************
** Use a real deadline rather than the fixture's delivery clock.
*******************************************************************************/
private HealthCheckInput input()
{
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5)));
}
/*******************************************************************************
** The runner has package-private sanitize; assert the raw boundary is clean.
*******************************************************************************/
private HealthCheckOutcome run(HealthCheck check) throws Exception
{
var outcome = check.run(input());
clean(outcome);
return (outcome);
}
/*******************************************************************************
** Check both message and serialized details on every success/failure path.
*******************************************************************************/
private void clean(HealthCheckOutcome outcome)
{
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SENTINEL, "recipient@example.test", "sender@example.com", "127.0.0.1");
}
/*******************************************************************************
** Inject a failure after a real database read, like a failing table customizer.
*******************************************************************************/
public static class PoisonedQuery extends AbstractPostQueryCustomizer
{
/*******************************************************************************
** The adapter must discard this untrusted exception message.
*******************************************************************************/
@Override
public List<QRecord> apply(List<QRecord> records)
{
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
}
}
}
@@ -0,0 +1,116 @@
/*
* 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.health;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import com.kingsrook.qbits.email.H2Support;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
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.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The home reads only the central rollup and honors health read permissions.
*******************************************************************************/
class EmailHealthWidgetTest
{
/*******************************************************************************
** Release thread-local state.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** A host without central health still has an honest empty status line.
*******************************************************************************/
@Test
void missingHealthMetadata() throws Exception
{
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
assertThat(instance.getWidget("emailHealth").getType()).isEqualTo(WidgetType.STATISTICS.getType());
var data = render();
assertThat(data.getCount()).isEqualTo("Not checked yet");
assertThat(data.getCountContext()).contains("not configured");
assertThat(data.getCountURL()).isNull();
}
/*******************************************************************************
** States come from health_component, never provider rows or free-form errors.
*******************************************************************************/
@Test
void centralRollupAndPermissions() throws Exception
{
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
new HealthMetaDataProvider().defineAll(instance, "rdbms", table ->
{
// H2 needs no additional field metadata.
});
try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) instance.getBackend("rdbms")))
{
connection.createStatement().execute("CREATE TABLE health_component (id INT PRIMARY KEY, name VARCHAR(100), status VARCHAR(100))");
QContext.getQSession().setPermissions(Set.of("email.view"));
assertThat(render().getCountContext()).contains("access required");
assertThat(render().getCountURL()).isNull();
QContext.getQSession().setPermissions(Set.of("email.view", "health.read"));
instance.setEnforcePermissionsInCoreActions(true);
assertThat(render().getCount()).isEqualTo("Not checked yet");
assertThat(render().getCountContext()).contains("Checks run every 5 minutes");
connection.createStatement().execute("INSERT INTO health_component VALUES (1, 'email', 'DOWN'), (2, 'other', 'UP')");
assertThat(render().getCount()).isEqualTo("Down");
String link = URLDecoder.decode(render().getCountURL(), StandardCharsets.UTF_8);
assertThat(link).startsWith("/app/health_check?filter=").contains("component", "email");
connection.createStatement().execute("UPDATE health_component SET status = 'UP' WHERE id = 1");
assertThat(render().getCount()).isEqualTo("Healthy");
connection.createStatement().execute("UPDATE health_component SET status = 'DEGRADED' WHERE id = 1");
assertThat(render().getCount()).isEqualTo("Degraded");
connection.createStatement().execute("UPDATE health_component SET status = 'S3NT1NEL-secret' WHERE id = 1");
assertThat(render().getCount()).isEqualTo("Not checked yet");
assertThat(render().getCountContext()).doesNotContain("S3NT1NEL-secret");
}
}
/*******************************************************************************
** Render through the same renderer installed in widget metadata.
*******************************************************************************/
private StatisticsData render() throws Exception
{
return ((StatisticsData) new EmailHealthWidget().render(new RenderWidgetInput()).getWidgetData());
}
}