Compare commits

...
Author SHA1 Message Date
CI 63915e2bfb Bump version to 0.1.0-RC.11 [skip ci] 2026-11-07 17:29:48 +00:00
james.maes affd9ac027 Merge pull request 'release: merge develop into release/0.1 (Gitea-aligned RC)' (#50) from align/develop-into-release-0.1 into release/0.1
build / maven (push) Successful in 2m27s
maven / build (push) Successful in 2m27s
2026-11-07 17:22:13 +00:00
james.maesandClaude Opus 5.5 452a7e4a38 Merge develop into release/0.1 (Gitea-aligned RC)
build / maven (push) Canceled after 0s
maven / build (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:21:57 -06: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-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 / node-9 (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 / 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 / not-enrolled (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 1ad4189619 ci: centralize release branch verification and workflows (#38)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 13:27:03 -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
CircleCI 38390d74bd Bump version to 0.1.0-RC.10 [skip ci] 2026-10-19 14:38:57 +00:00
github-actions[bot] 247f842e05 Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-19 14:36:38 +00: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
CircleCI da193099b6 Bump version to 0.1.0-RC.9 [skip ci] 2026-10-16 17:11:02 +00:00
james.maesandClaude Opus 5.5 d1215ad478 Merge develop into release/0.1 for 0.1.0-RC.9 (#29 clearer postal-address message)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:06:06 -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
CircleCI 94bed610cd Bump version to 0.1.0-RC.8 [skip ci] 2026-10-16 16:01:11 +00:00
github-actions[bot] 5d863aed12 Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 15:58:16 +00: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
CircleCI 1051f1780d Bump version to 0.1.0-RC.7 [skip ci] 2026-10-15 06:22:43 +00:00
james.maesandClaude Opus 5.5 03c813583c Merge develop into release/0.1 for 0.1.0-RC.7 (#20 help bundle, #16 health, #22, #23 bundle version)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:18:16 -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.maesandClaude Opus 5.5 5bfd7f21e4 Merge develop into release/0.1 for 0.1.0-RC.7 (#20 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:30:02 -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
CircleCI 5a77659e85 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-14 23:10:05 +00:00
james.maesandClaude Opus 5.5 e1c0d1f7df Merge develop into release/0.1 for 0.1.0-RC.6 (#17 indexes and FKs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:05:04 -05:00
CircleCI f6b26737ae Bump version to 0.1.0-RC.5 [skip ci] 2026-10-14 22:39:08 +00:00
james.maesandClaude Opus 5.5 2e516c6f0f Merge develop into release/0.1 for 0.1.0-RC.5 (#19 brand seed, Setup card)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:34:48 -05:00
CircleCI 5f40c93727 Bump version to 0.1.0-RC.4 [skip ci] 2026-10-14 21:52:46 +00:00
james.maesandClaude Opus 5.5 12a17808a8 Merge develop into release/0.1 for 0.1.0-RC.4 (#18 EmailJobEffectTarget, QQQ RC.7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:48:00 -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
CircleCI f495dcc9ae Bump version to 0.1.0-RC.3 [skip ci] 2026-10-14 15:19:50 +00:00
james.maesandClaude Opus 5.5 040fb3e35c Merge develop into release/0.1 for 0.1.0-RC.3 (#15 readiness checks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:16:28 -05:00
CircleCI a087e8b1f3 Bump version to 0.1.0-RC.2 [skip ci] 2026-10-14 14:48:15 +00:00
james.maesandClaude Opus 5.5 24c936453f Merge develop into release/0.1 for 0.1.0-RC.2 (#12 Message-ID and sendOnce, #13 public api)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:44:17 -05:00
CircleCI 1ff27e90be Bump version to 0.1.0-RC.1 [skip ci] 2026-10-14 12:38:05 +00:00
35 changed files with 1804 additions and 152 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
+12 -1
View File
@@ -5,10 +5,16 @@ 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.
- 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.
@@ -16,6 +22,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
- `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.
+29 -4
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.
@@ -181,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
@@ -458,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.
@@ -612,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-SNAPSHOT</revision>
<revision>0.1.0-RC.11</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,13 +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.health.EmailHealthChecks;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
@@ -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,6 +229,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
output.add(EmailApp.app(qBitConfig, tables));
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
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()))
@@ -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.
@@ -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"));
+3 -1
View File
@@ -1,5 +1,5 @@
bundle: qbit-email
version: 0.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: app:email;slot:help
@@ -530,6 +530,8 @@ entries:
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
+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;");
}
/*******************************************************************************
@@ -48,13 +48,17 @@ 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("0.1.0-SNAPSHOT");
assertThat(bundle.getVersion()).isEqualTo(artifactVersion).isEqualTo(EmailQBitProducer.VERSION);
});
}
@@ -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());
}
}