Compare commits

..
Author SHA1 Message Date
forge-recovery f8699ba7f2 ci: enroll shared Gitea lifecycle
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (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-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (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 / 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 / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 10s
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-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
Shared Java Maven CI with Docker / verify (push) Successful in 48s
Shared Java Maven CI with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 1h56m34s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 15s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 1h57m40s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
2026-11-02 18:58:37 +00:00
ab8358a41f ci: add the isolated postpublish job to the QRun release caller
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 / 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 / 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 / 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 / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 11s
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-docker-3-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: native / native-result (pull_request) Successful in 8s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h37m39s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 40s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 2h55m52s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:49:21 +00:00
d15c4e0ef9 ci: move the QRun release caller to the thin v2 contract
Shared Java Maven CI with Docker / gate (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 / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (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-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (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 4s
Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 19s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
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
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h2m58s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h3m13s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:13 +00:00
forge-recoveryandCodex e0b18a6c18 ci: add the identical QRun release caller
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 41s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 10s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Failing after 53m18s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
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 / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (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: 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) Successful in 14s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:11:07 +00:00
james.maesandCodex 58f1bf17ab ci: enroll in the shared QQQ orb (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 19:52:13 -05:00
james.maesandCodex cf47a9fd89 ci: use the shared orb pipeline on main (#36)
* ci: use the shared orb pipeline on main

Co-Authored-By: Codex <noreply@openai.com>

* ci: use the shared branch-aware CodeQL worker

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:18:10 -05:00
158 changed files with 363 additions and 27451 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.8
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:
ignore: /.*/
-20
View File
@@ -1,20 +0,0 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
-3
View File
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+25
View File
@@ -0,0 +1,25 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
with:
runner: linux-small
docker-runner: container-build
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
-25
View File
@@ -1,25 +0,0 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
-31
View File
@@ -1,31 +0,0 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+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
-25
View File
@@ -1,25 +0,0 @@
# 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:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
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
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+15 -16
View File
@@ -1,21 +1,20 @@
# Maven
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
Thumbs.db
# Logs
*.log
-3
View File
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+15
View File
@@ -0,0 +1,15 @@
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: none
test_branch_policy: all
publish:
snapshot: false
release_candidate: false
release: false
asciidoc: false
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md
This file provides guidance to coding agents working in this repository (same content as CLAUDE.md).
## Project Overview
**QBit Email**: one email service for a QQQ system (outbox, providers, templates with branding,
delivery tracking, suppression, unsubscribe, PHI-safe sending; inbound in phase 2).
Design: `docs/DESIGN.md`.
## Build Commands
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test
./mvnw verify # tests + Checkstyle + JaCoCo gate (70% instructions, 90% classes)
```
## Architecture
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
- `outbox`: EmailService, queue-time template rendering, protected resend and branded ad-hoc rendering.
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
- `template`: parsed variable language/schema, locale resolution, immutable versions, version-bound PHI approval, MJML compilation, lazy secure-message built-in and inline preview.
- `render`: CommonMark extensions, table-based light/dark layout, contrast validation, text conversion and unsubscribe-link signing (handler is phase c).
- `phi`: RC.5 classification/token stand-ins and AES-GCM encryption; only dispatch decrypts bodies.
- `app/EmailHome`: viewer-scoped counts and inline cards, with standard hourly/suppression chart widgets.
- Version operations serialize on the template row; active/approved version selectors are parent-scoped.
- Preview UI API endpoint awaits RC.6; webhook/unsubscribe handlers remain phase c.
## Invariants
- Every send goes through the outbox (`email_message`); nothing sends inline.
- Provider credentials are environment-variable NAMES; values are never stored, logged or returned.
- PHI and PII never reach a body or a variable snapshot unless the template is `phiApproved`; snapshots follow the secured-data redaction and HMAC rules.
## Naming (owner rule)
- Tables are lowercase snake_case, prefixed `email_`; the backend name is the table name; columns are snake_case.
- Permissions use the full qbit name: `email.view`, `email.send`, `email.manage`, `email.admin`.
- Data lives in standard visible tables. Processes are table or record actions, never in the nav. The nav lists only the main tables.
- UI endpoints are `/qqq/v1` spec extensions (`docs/UI-API-EXTENSIONS.md` in QRun-IO/qqq); provider webhooks are under `/qqq/hooks/email/...`.
## Code Style
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`).
- `vX.Y.Z` tags publish releases (GA tags only).
- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`.
-37
View File
@@ -1,37 +0,0 @@
# Changelog
All notable changes to QBit Email are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- `EmailApi.sendOnce` as the public durable-effect handoff, with API-only outcome/result types, committed enqueue/replay semantics and suppression/transaction refusal parity. Exact idempotency-key and RFC Message-ID lookups return `EmailApiMessage`, which now includes `rfcMessageId` across all lookup routes. Public requests support ordinary caller transactions; `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internals. H2 parity, reconciliation, storage-failure and API-boundary coverage; no new dependencies.
- Stable `com.kingsrook.qbits.email.api` contract for cross-qbit sends, create-once templates, metadata-only message lookups, enabled sender addresses, default provider selection and secret-safe readiness (including marketing and PHI prerequisites). JDK/QQQ-only signatures, complete request/status mapping tests and a reflection package-boundary guard; no new dependencies.
- Documented both supported cross-qbit routes: core-only `SendMessageAction` via `qbitEmail`, and the public API for richer sends and lookups.
- Persisted unique RFC Message-IDs, stable across delivery attempts, with SMTP/Gmail SMTP, SES raw MIME and Postmark/SendGrid/Mailgun request propagation (SES replaces the header downstream).
- Core-independent `EmailEffects` handoff, reference lookups by idempotency key or RFC Message-ID, refusal mapping and an RC.7 binding design sketch; offline retry and provider-contract tests.
- Checkstyle now checks test sources as well as main sources; existing test imports and formatting corrected.
- Provider sender addresses: child table `email_provider_sender_address` (validated lowercase address, display name, one default, enabled, note; unique per provider; domain bound to the provider's sending domain; admin-only), shown on the provider record. `EmailRequest.withFromAddressId`, `from_address_id` on messages, refusal of a from address that is not an enabled row of the resolved provider, and the provider `from_address` / `from_name` kept as the fallback.
- Contingent possible-value filters on the new from-address pickers (send processes by `providerId`, Send Test Email by the acted-on provider, message `fromAddressId` by `providerId`) and on the attempt and event message pickers (by `providerId`) and the suppression source event (by `sourceMessageId`); the send processes also take `providerId`.
- Phase (c): authenticated SNS, Postmark, SendGrid and Mailgun webhooks, durable inbox deduplication and Mailgun nonce replay protection; configurable Javalin routes.
- Atomic scheduled event processing, per-recipient event history, monotonic delivery state, category-aware suppression, optional daily complaint alerts and FAILED inbox Reprocess with Review/Result.
- One-click unsubscribe and escaped self-contained light/dark confirmation pages using the existing envelope-bound signer; account/security tokens refused.
- Event-based home totals, PostgreSQL/H2 tracking DDL, provider setup instructions, test-only SNS certificate/key and real HTTP, crypto, parser, concurrency, retry and privacy tests. Preferences stays deferred until core #1162 reaches an RC.
- Phase (a), part 2: versioned Markdown/HTML/MJML templates, typed variable schemas, escaped blocks/loops/fallbacks, branded light/dark layout and contrast validation.
- New Version, Activate Version and version-bound Approve For PHI actions, edit permissions, immutable history and parent-scoped version selectors.
- Sender-scoped record variables, classification stand-in, HMAC snapshot tokens, AES-GCM bodies, encrypted query displays and protected resend.
- Lazy secure-message built-in, typed templated core bridge, process JSON variables, inline preview and viewer-scoped dashboard charts/cards.
- PHI canary egress, masking, lifecycle, parent-filter, rendering, branding and GreenMail template tests.
- Phase (b) Postmark, SendGrid and Mailgun HTTP transports with fixed HTTPS endpoints, bounded requests, tracking options, safe error classification and Retry-After support; no new dependencies or filesystem storage.
- Strict Gmail SMTP credential/STARTTLS validation, provider-specific credential and webhook environment defaults, and the [provider setup guide](docs/PROVIDERS.md).
- HTTP request-contract tests and local-server end-to-end outbox tests covering success, provider failures, permanent rejection, retries and secret-safe stored errors.
- Phase (a), part 1 email tables, PostgreSQL DDL, enum sources, child relations, permissions and Email app navigation.
- Provider validation with environment-name allowlists and presence-only credential displays.
- Transactional ad-hoc EmailService, idempotency, suppression and a replaceable Markdown/HTML/text renderer.
- Durable dispatcher with unique attempt claims, leases, retry/dead-letter handling, shared provider rate limits, health probes and optional alerts.
- SMTP with authentication/TLS/timeouts, SES v2 raw MIME, and a Gmail SMTP preset.
- 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).
+10 -53
View File
@@ -1,60 +1,17 @@
# CLAUDE.md
# qbit-template-extension
This file provides guidance to Claude Code when working with code in this repository.
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
## Project Overview
## QQQ 4.0 baseline — 2026-09-24
**QBit Email**: one email service for a QQQ system (outbox, providers, templates with branding,
delivery tracking, suppression, unsubscribe, PHI-safe sending; inbound in phase 2).
Design: `docs/DESIGN.md`.
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
## Build Commands
## Knowledge base
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test
./mvnw verify # tests + Checkstyle + JaCoCo gate (70% instructions, 90% classes)
```
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
## Architecture
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
- `outbox`: EmailService, queue-time template rendering, protected resend and branded ad-hoc rendering.
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
- `template`: parsed variable language/schema, locale resolution, immutable versions, version-bound PHI approval, MJML compilation, lazy secure-message built-in and inline preview.
- `render`: CommonMark extensions, table-based light/dark layout, contrast validation, text conversion and unsubscribe-link signing (handler is phase c).
- `phi`: RC.5 classification/token stand-ins and AES-GCM encryption; only dispatch decrypts bodies.
- `app/EmailHome`: viewer-scoped counts and inline cards, with standard hourly/suppression chart widgets.
- Version operations serialize on the template row; active/approved version selectors are parent-scoped.
- Preview UI API endpoint awaits RC.6; webhook/unsubscribe handlers remain phase c.
## Invariants
- Every send goes through the outbox (`email_message`); nothing sends inline.
- Provider credentials are environment-variable NAMES; values are never stored, logged or returned.
- PHI and PII never reach a body or a variable snapshot unless the template is `phiApproved`; snapshots follow the secured-data redaction and HMAC rules.
## Naming (owner rule)
- Tables are lowercase snake_case, prefixed `email_`; the backend name is the table name; columns are snake_case.
- Permissions use the full qbit name: `email.view`, `email.send`, `email.manage`, `email.admin`.
- Data lives in standard visible tables. Processes are table or record actions, never in the nav. The nav lists only the main tables.
- UI endpoints are `/qqq/v1` spec extensions (`docs/UI-API-EXTENSIONS.md` in QRun-IO/qqq); provider webhooks are under `/qqq/hooks/email/...`.
## Code Style
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`).
- `vX.Y.Z` tags publish releases (GA tags only).
- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`.
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
-3
View File
@@ -1,3 +0,0 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
-3
View File
@@ -1,3 +0,0 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
-6
View File
@@ -1,6 +0,0 @@
# Contributing to QBit Webhooks
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
* Commits are signed and follow Conventional Commits.
* Build and test locally with `./mvnw verify` (Java 21).
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2024 Kingsrook, LLC
Copyright 2022-2025 QRunIO, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
QBit Email
Copyright 2026 Kingsrook, LLC
qBit Template - Extension
Copyright 2022-2025 QRunIO, LLC
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
This product includes software developed at QRunIO, LLC (https://qrun.io/).
Licensed under the Apache License, Version 2.0.
+29 -598
View File
@@ -1,611 +1,42 @@
# QBit Email
# QBit Template: Extension
One email service for a QQQ system: a durable outbox (`email_message`) with retries, dead
letters, idempotency keys, rate limits and provider health; SMTP, Amazon SES, Postmark,
SendGrid, Mailgun and Gmail SMTP transports; versioned Markdown, HTML and MJML templates with
dark-mode-safe branding; classified variables and PHI protection; preview widgets; a
real-data home; delivery events, suppression and one-click unsubscribe; and the core messaging bridge. Every send goes through the outbox.
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
The [design](docs/DESIGN.md) also specifies the deferred Preferences UI (core #1162),
consumer migrations and inbound mail. Preview uses the standard widget endpoint; the
dedicated preview API arrives with QQQ RC.6. Attachments are refused until
qbit-object-storage is wired in.
## What is an Extension QBit?
## Install
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
- New backend implementations
- Authentication mechanisms
- Audit capabilities
- Table customizers
- Action handlers
Requires Java 21, QQQ **4.1.0-RC.5**, and a transactional RDBMS with the supplied unique
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
The physical table names equal their metadata names; `backendName` selects the host's
QQQ database backend, as it does in qbit-webhooks.
## Quick Start
```xml
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
</dependency>
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
## Structure
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleExtensionQBitConfig.java # Configuration
├── ExampleExtensionQBitProducer.java # Entry point
└── customizers/
└── ExampleTableCustomizer.java # Table customization
```
```java
new EmailQBitProducer()
.withQBitConfig(new EmailQBitConfig()
.withBackendName("rdbms")
.withSchedulerName("scheduler"))
.produce(qInstance);
```
## Key Characteristics
The scheduler must already exist. Without a scheduler, callers can enqueue and run
`EmailDispatcher` or Send Now manually; automatic dispatch is not installed.
Configuration is looked up from the current `QInstance` on every call, never cached
in a static variable.
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
## Tables and permissions
## Documentation
| Table | Navigation | Writes |
|---|---|---|
| `email_message` | Messages | Qbit only; people use record actions |
| `email_message_attempt` | Attempts on a message | Qbit only |
| `email_event` | Events on a message | Qbit only; readable with `email.view` |
| `email_webhook_inbox` | Inbox on a provider | Qbit only; readable with `email.view`; Reprocess requires `email.manage` |
| `email_template` | Templates | `email.manage`; template edit grants also apply |
| `email_template_version` | Versions on a template | Draft edits only; lifecycle uses record actions |
| `email_brand` | Brands | `email.manage`; template edit grants also apply |
| `email_suppression` | Suppressions | `email.manage`; changing/removing a complaint needs `email.admin` |
| `email_provider` | Providers, with child Messages | `email.admin`, including reads and test sends |
| `email_provider_sender_address` | Sender Addresses on a provider | `email.admin`, including reads |
`email.view` reads messages, templates, versions, brands and suppressions.
`email.send` permits the Java service, bridge and backend send process. Reading
messages requires `email.view`, `email.manage` or `email.admin`; send alone grants no read access.
`email.manage` also allows sending, Resend, Cancel and Send Now. `email.admin`
implies all permissions. Grant `email.hasAccess` for the app's derived base access.
Enable the host's `QInstance.enforcePermissionsInCoreActions` for direct Java action
permission enforcement; the tables and processes use the shared custom checker.
Trusted system callers use `SystemWrites.call(...)`, which opens core's authorized
scope and still preserves record security locks.
All tables and columns follow the `email_`/snake_case database convention. Java field
names are camelCase, matching the webhooks reference. System tables disable direct
insert/update/delete capabilities. Labels contain no status decorations.
## Providers and credentials
See the [provider setup guide](docs/PROVIDERS.md) for row fields, DNS/domain setup,
limits, Gmail app passwords and provider webhook setup.
| Kind | Transport | Status |
|---|---|---|
| SMTP | Relay or authenticated SMTP, STARTTLS / TLS | Available |
| SES | AWS SES v2, raw MIME | Available |
| POSTMARK | HTTPS JSON, server token and message stream | Available |
| SENDGRID | HTTPS JSON, bearer API key | Available |
| MAILGUN | HTTPS MIME multipart, US / EU | Available |
| GMAIL_SMTP | smtp.gmail.com:587, STARTTLS and app password | Available |
| GMAIL_API | OAuth Gmail API | Deferred; use GMAIL_SMTP |
Create the first enabled provider with `isDefault=true`. When any providers are
enabled, exactly one must be the enabled default. Switch defaults in a batch update,
clearing the old default first. A nullable unique `default_slot` backs the validation.
Providers with message history cannot be deleted; disable them instead.
**Sender addresses.** A provider can own any number of verified from addresses, kept in
`email_provider_sender_address` and shown as a Sender Addresses list on the provider record
(not in the nav; `email.admin` reads and writes, like the provider). Each row has an
`address` (validated, stored lowercase, line breaks refused), an optional `displayName`,
`isDefault`, `enabled` and a `note`. An address is unique per provider, at most one enabled
row per provider is the default, and when the provider has a `sendingDomain` every address must
be in that domain (changing the domain with other-domain rows is refused). Deleting a provider
deletes its rows. Existing databases add the table and the nullable
`email_message.from_address_id` column from `docs/ddl/email-postgres.sql`.
The provider's own `fromAddress` / `fromName` stay the fallback, so nothing changes until a
row exists. A row marked default wins over the fallback. On a send, `withFromAddressId(id)`
(or `fromAddressId` on the send processes) must name an enabled row of the resolved provider,
and once a provider has rows an explicit `withFrom(...)` must match one of them by address;
either failure is `REFUSED` with "That from address is not set up for provider <name>". With
no rows, `withFrom(...)` behaves as before. The chosen row is recorded on the message as
`fromAddressId`, and a resend reuses it.
The from address pickers are contingent: `fromAddressId` on the send processes lists only the
rows of the chosen `providerId` (`providerName` is still accepted for API callers, and the two
must agree), and the Send Test Email action lists only the rows of the provider being tested.
That action's form has no record id, so a prepare step publishes the single selected provider id
as the process value `possibleValueFilterValueId`, which the picker's filter reads after `${input.id}`.
For an unauthenticated relay, use kind `SMTP`, host/port, security `NONE`, and a
`fromAddress`. Leave credential names empty. Authenticated SMTP uses `usernameEnvName`
and `secretEnvName`; security is `NONE`, `STARTTLS` (required) or `TLS`. Timeouts are
10 seconds to connect and 15 seconds each to read and write. The Gmail SMTP preset
uses `smtp.gmail.com:587` with required STARTTLS.
SES needs a `region`, optionally `configurationSet`. With no credential references
it uses the AWS default chain. Otherwise both username and secret references are
required and resolve to the access key and secret key. SES sends raw MIME with explicit
envelope destinations, keeping BCC recipients out of the raw message headers. SDK
retries are disabled so outbox attempts own retry accounting; an API call is bounded
at 40 seconds.
Credential fields contain **environment variable names**, matching
`^[A-Z_][A-Z0-9_]{0,99}$`. The effective allowlist is `allowedEnvNames` plus these defaults
for the selected kind:
| Kind | Default allowed names |
|---|---|
| SMTP / GMAIL_SMTP | `EMAIL_SMTP_USERNAME`, `EMAIL_SMTP_PASSWORD`, `EMAIL_GMAIL_USERNAME`, `EMAIL_GMAIL_APP_PASSWORD`, `GMAIL_USERNAME`, `GMAIL_APP_PASSWORD` |
| SES | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` |
| POSTMARK | `POSTMARK_SERVER_TOKEN`, `POSTMARK_WEBHOOK_PASSWORD`; legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD` |
| SENDGRID | `SENDGRID_API_KEY`, `SENDGRID_WEBHOOK_PUBLIC_KEY`; legacy `EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY` |
| MAILGUN | `MAILGUN_API_KEY`, `MAILGUN_WEBHOOK_SIGNING_KEY`; legacy `EMAIL_MAILGUN_API_KEY`, `EMAIL_MAILGUN_SIGNING_KEY` |
| GMAIL_API | `EMAIL_GMAIL_CLIENT_ID`, `EMAIL_GMAIL_CLIENT_SECRET`, `EMAIL_GMAIL_REFRESH_TOKEN` |
Allowlisting does not automatically select a name. Empty SMTP references mean no auth;
empty SES references mean the default chain. Explicit SES references use a static
access/secret pair; temporary session credentials belong in the default AWS chain.
`withSecretResolver(Function<String,String>)` defaults to `System::getenv`. Values are
resolved during an attempt and never retained, audited, returned or logged. The
provider record's display-only fields return only `Set` / `Not set`; checking presence
necessarily invokes the resolver too. Enqueue and internal queries do not resolve it.
POSTMARK, SENDGRID and MAILGUN require a secret environment name. Mailgun also
requires a sending domain and US/EU region. Gmail SMTP requires both credential
names and STARTTLS. GMAIL_API is refused with
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
HTTP transports use fixed HTTPS endpoints, 10-second connect and 30-second request
timeouts, and no redirects. HTTP 429/5xx retry with optional Retry-After; account
and credential failures pause the provider. Tracking is explicitly controlled by
`trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c)
receiver idempotently when the provided Javalin middleware is present.
## Sending from another qbit
There are two supported routes. Both queue through the durable outbox; a successful
call acknowledges enqueueing, not delivery.
**1. QQQ core only, for simple subject/body email.** The host installs qbit-email;
the consuming qbit needs no qbit-email dependency or imports. Use
`SendMessageAction` with `messagingProviderName` set to `qbitEmail` (the default
bridge name; hosts can configure another name):
```java
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName("qbitEmail")
.withTo(new Party().withAddress("pat@example.com"))
.withSubject("Ready")
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Your export is ready.")));
```
**2. The published `com.kingsrook.qbits.email.api` package**, for templates,
idempotency, categories, linked records, sender selection and delivery lookups.
Consumers with a qbit-email dependency import only this package:
```java
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
EmailApiResult result = EmailApi.send(new EmailApiRequest()
.withTo("pat@example.com")
.withTemplateName("order_ready")
.withVariable("order_number", "A-1")
.withVariable("customer_name", "Pat")
.withIdempotencyKey("orders:42:ready")
.withLinkedRecord("orders", 42)
.withSender("orders", "ready"));
```
`EmailApi.ensureTemplate(EmailApiTemplate)` creates a template once; see
[Templates from another qbit](#templates-from-another-qbit).
For durable job effects, use `EmailApi.sendOnce(request, effectKey)`. It assigns
the effect key as the request's idempotency key and returns `EmailApiEffectResult`
with `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE` or `REFUSED` and a nullable
`EmailApiMessage`. Success means the outbox has committed; duplicate handoffs return
the same row and RFC Message-ID. Null requests, null/blank/over-500-character keys,
conflicting request keys and caller transactions are refused. Suppressed rows remain
refused on replay and include their audit metadata; refusals without a row have no
message. Database failures throw `QException` for retry or reconciliation. Ordinary
`EmailApi.send` accepts `request.withTransaction(transaction, backendName)` when a
caller-owned or cross-backend deferred enqueue is needed. See [durable job effects](docs/DURABLE-JOBS.md).
`EmailApi.isInstalled(instance)` detects whether the qbit was produced.
`defaultProviderName(instance)` resolves an enabled configured/default provider;
`listSenderAddresses(providerName)` lists enabled sender rows (null selects the
default). `readiness(instance, category)` reports configuration prerequisites,
including MARKETING's public URL, unsubscribe key and default brand postal address.
For a PHI-approved template, use `readiness(instance, category, true)` to also check
the body key and provider PHI/tracking policy. PHI is a template approval state,
not a category. Readiness is a preflight, not proof that a particular send will pass.
`getMessage(id)`, `getMessageByIdempotencyKey(key)`, `getMessageByRfcMessageId(rfcMessageId)`
and `findMessagesByIdempotencyKeyPrefix(prefix, limit)` expose only
id, delivery status, category, idempotency key, RFC Message-ID, provider message id, template name,
linked table/id and create/sent/delivered timestamps. They never return addresses,
subjects, bodies or variable snapshots. Prefixes are literal and nonblank; limits
must be positive; results are ordered by ascending message id. Missing messages
return an empty Optional/list. Exact-key lookups also return empty for null/blank
values; RFC lookup takes the stored bracketed header, not a provider response id.
Ordinary enqueue results have `QUEUED`, `DUPLICATE`,
`SUPPRESSED` or `REFUSED`; delivery status is a separate string on `EmailApiMessage`.
Database calls require the caller's initialized `QContext`; methods accepting an
installed instance require it to be the current context instance. Lookups run as
the email system, so callers must authorize any exposure to their own users.
Sending retains the existing permission, schema, PHI, suppression and recipient
rules. Database failures propagate as core `QException`; invalid template definitions
and lookup arguments throw `IllegalArgumentException`. No secret values are exposed.
The API uses only JDK, QQQ core and its own API types in published signatures.
Changes within a `0.x` minor release line are additive only. Internal packages
(including runtime, configuration, outbox, metadata, support and template classes)
are not API and may change. `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef`
are deprecated internals; `EmailApi` is the sole public qbit-email surface.
The internal service details below describe host and
qbit-email implementation behavior, not additional cross-qbit contracts.
## Internal sending service
```java
EmailSendResult result = EmailService.send(new EmailRequest()
.withTo("Pat <pat@example.com>")
.withCc("copy@example.com")
.withSubject("Your export is ready")
.withMarkdownBody("[Open your export](https://app.example.com/exports/42)")
.withCategory(EmailCategory.NOTIFICATION)
.withIdempotencyKey("export:42")
.withLinkedRecord("export", 42)
.withSender("exports", "complete"));
```
Use `withHtmlBody` or `withTextBody` instead of Markdown, with optional explicit text
alongside HTML. `withFrom`, `withReplyTo`, `withBcc`, `withProviderName`, `withSendAfter`,
and `withLocale` are supported. `sendingDomain`, when present, must match the sender's
domain. Addresses are parsed strictly; header newlines are refused. Markdown raw HTML
and unsafe URL schemes are disabled. Templates always use the validated template renderer. The ad-hoc renderer extension
remains compatible. Both alternatives are stored at enqueue time and reused on retries.
Results expose `messageId()`, `status()` (`QUEUED`, `DUPLICATE`, `SUPPRESSED`, `REFUSED`)
and `reasons()`. Idempotency is global within the outbox. A repeated key returns the
original id; unique constraints protect races. Idempotent inserts briefly lock the
oldest provider row so concurrent service calls do not abort a caller-owned SQL
transaction with a duplicate-key error.
`withTransaction(transaction, backendName)` joins a matching backend transaction.
The one-argument overload assumes the email backend. A different backend registers
an after-commit insert, discarded on rollback. Its immediate result has a null id
and a reason explaining that enqueue is deferred: an auto-generated id does not
exist before that callback runs. Cross-backend callbacks have the same crash window
as qbit-webhooks' fallback; use the same backend when atomic enqueue is required.
Without a caller transaction, the service owns and commits its transaction.
For multi-tenant hosts, configure `securityFields` and `recordSecurityLocks`, add the
matching columns to the DDL, and provide the request's `securityValues`. Security
values propagate to attempts and resends.
Suppression compares canonical lowercase mailboxes. ALL applies across categories,
except UNSUBSCRIBE cannot block TRANSACTIONAL or SECURITY. Category suppressions apply
to NOTIFICATION and MARKETING. Expired rows are ignored. Partial suppression drops
only matching recipients and records them in `suppressedAddresses`; all-suppressed
messages remain visible with SUPPRESSED status and never consume an attempt.
## Templates, variables and branding
Create an `email_template`, then use **New Version** to copy the active or latest
version into a DRAFT. Edit its source, schema and synthetic sample values. **Activate
Version** validates syntax, samples, classifications, brand contrast and optional
MJML compilation, then retires the previous active version for that locale in one
transaction. ACTIVE and RETIRED versions are immutable. Template-specific
`editPermissionName` applies to CRUD and actions, in addition to the email permission;
even email administrators need that explicit grant. Trusted system writes may bypass it.
```java
EmailService.send(new EmailRequest()
.withTemplateName("order_ready")
.withTo("pat@example.com")
.withRecordVariable("order", "order", 123)
.withVariable("count", 3)
.withLocale("en"));
```
A schema is a JSON list, for example:
```json
[
{"name":"order","label":"Order","type":"RECORD","required":true},
{"name":"count","label":"Items","type":"NUMBER","required":true},
{"name":"link","type":"URL","classification":"PUBLIC"}
]
```
Types are STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL, LIST and RECORD. Optional
`table`/`field` bind a variable's classification to metadata. URL values must be
absolute HTTP(S) URLs. Use `{{name}}`, `{{order.number}}`, `{{name|fallback text}}`,
`{{#if count}}...{{/if}}`, and `{{#each items}}{{@index}}: {{this.name}}{{/each}}`.
`this` also supports scalar list elements. Empty/null/false/zero values are false.
Unknown variables, unsupported helpers, unclosed expressions and mismatched blocks
produce line-numbered errors. Records and lists allow dynamic child paths; declare
specific dotted entries when child types/requiredness matter. There is no raw output.
HTML values are escaped; Markdown values cannot inject Markdown syntax. Subject and
text contexts use plain values, with subject newlines refused.
Built-ins are `brand.*` (snake_case names such as `label`, `site_url`, `primary_color`),
`message.id` (the actual outbox primary key), `now` (UTC), and `unsubscribe_url`.
The latter is empty for TRANSACTIONAL/SECURITY and when no notification opt-out key
is configured. The requested locale resolves to an ACTIVE version, falling back to
`en`. The template supplies the category unless the request overrides it.
Brand precedence is request name, template brand, the configured app's brand,
default brand, then the built-in neutral brand. Markdown uses CommonMark tables and
autolinks; raw HTML is escaped. `[Label](https://example.com){.button}` creates a table
button. The 600px shell includes preheader, logo variants, footer, social links and
light/dark colors; marketing requires a postal address. Brand saves and activation
require 4.5:1 text and button contrast in both modes. Optional `darkPrimaryColor` and
`darkButtonTextColor` fall back to their light counterparts. Colors use `#RRGGBB`.
HTML versions retain authored HTML, with escaped variables and an opt-out footer
before `</body>`. For MJML configure `withMjmlCompiler(new CliMjmlCompiler(binaryPath))`
or another compiler implementation. Compilation uses stdin/stdout only, with a
20-second timeout; the stored `compiledHtml` is reused for sends. No runtime files,
source files or compiler temp files are written by this qbit.
The record Preview widget uses synthetic `sampleVariables`, sanitized inline light
(390px maximum) and dark (600px maximum) samples plus wrapping plain text. Next's
sanitizer removes iframes and style blocks, so this preview is not an exact mail-client
emulator. Draft MJML is compiled in memory for preview when a compiler is configured;
preview never stores the result or changes the version.
### Templates from another qbit
A qbit that sends its own mail ships the template with its code instead of asking an
administrator to create it. Describe it with `EmailApiTemplate` and call
`EmailApi.ensureTemplate` at startup or before first use:
```java
boolean created = EmailApi.ensureTemplate(new EmailApiTemplate()
.withName("order_ready")
.withLabel("Order ready")
.withCategory(EmailApiCategory.TRANSACTIONAL)
.withSubject("Order {{order_number}} is ready")
.withBody("Hi {{customer_name}}, order {{order_number}} is ready for pickup.")
.withVariable("order_number", "Order number", "STRING", true, "PUBLIC")
.withVariable("customer_name", "Customer name", "STRING", true, null)
.withSampleVariables("{\"order_number\":\"A-1\",\"customer_name\":\"Pat\"}")
.withOwnerQbit("orders")
.withEditPermissionName("orders.edit"));
```
`kind` defaults to MARKDOWN (MJML and HTML are also accepted), `withTextBody` is
optional, and `withVariablesSchema(json)` replaces `withVariable` when you already have
the JSON. When no template of that name exists, `ensureTemplate` validates the definition like
Activate Version (syntax, schema, a render with the samples, MJML compile), creates the
template and an ACTIVE version 1 as the qbit, and returns true. When one exists it does
nothing and returns false, so people's edits and later versions are never overwritten;
the unique name settles concurrent callers. An invalid definition throws
`IllegalArgumentException` with plain reasons and writes nothing.
## PHI and PII
`withRecordVariable` reads through `QueryAction` in the sender's QContext; it does not
open an authorized scope. Core password masking is preserved. RC.5 does not enforce
ordinary `FieldSecurityLock` during QueryAction, so the email record loader also
applies the field lock against the sender's security keys before copying any values.
Classification precedence is reflective core `AiDataClassifications.ofField`, config
`classificationOverrides`, schema declaration, then PUBLIC.
PII/PHI variables require **Approve For PHI** (`email.admin`) on the exact version.
Approval records actor/date and `approvedVersionId`; it freezes an approved draft.
A new sensitive version must be approved before activation. Activating a version
without its own approval clears the template's old approval. Approval of one locale
version does not approve another locale. PHI-approved templates cannot be MARKETING,
use provider open/click tracking, or use a provider without `phiAllowed`. Provider
attestation and tracking are checked again immediately before dispatch.
`email_secure_message` is created transactionally on its first send. It needs only
a required URL variable `link` and brand built-ins; use it to send a sign-in link
without putting patient data into email.
PII/PHI snapshot values become `hmac:v1:<keyId>:<base64url>`, or `changed:v1` without
a valid base64 `QQQ_AUDIT_HMAC_KEY`. Record tokens bind table, field and primary key;
context tokens bind `email`, template name, variable name and message id. Context
booleans and values with fewer than four distinct characters always use `changed:v1`.
`shred:v1` is reserved for future core wipe-rule record fields and is never produced
for context values. The classification and token helpers are explicit RC.7 stand-ins.
PHI-approved subjects and HTML/text bodies are AES-256-GCM encrypted as
`enc:v1:<base64url(iv|ciphertext|tag)>`, using SHA-256 of `QQQ_EMAIL_BODY_KEY`.
Missing keys refuse the send before an outbox insert. Only dispatch decrypts; query,
record and API displays show `[encrypted]`, while home shows `Secure message`.
Resend preserves ciphertext and policy; its token snapshot stays bound to the original
message, which the new message links to. Errors and logs omit provider diagnostics
and content. Keys resolve through `secretResolver`; no secret values are stored.
NOTIFICATION/MARKETING templates prepare footer links and headers using
`QQQ_EMAIL_UNSUBSCRIBE_KEY` and `publicBaseUrl`; marketing refuses without them.
Tokens bind the message, recipient-envelope hash and category. The receiving route
is implemented: GET only shows a light/dark confirmation page; explicit POST upserts
category suppression and records UNSUBSCRIBED. Invalid or account/security tokens
return 404. The legacy token covers every To/Cc/Bcc recipient, so use one mailbox
per opt-out message for individual preferences. Preferences UI remains deferred
until core #1162 ships in an RC.
The home uses viewer-scoped counts and queries, UTC hour/day charts, provider health
(admins), and 20 recent messages with masked recipients. No sample data or sampled
counts are used. Delivered/bounced/complained totals count recipient events and
use their occurred dates; sent/dead-letter totals count messages.
## Delivery and actions
Each claim inserts a unique `(message_id, attempt_number)` row and commits SENDING
with a lease before network I/O. Provider-row database locks serialize rate checks
and claims across replicas; they are released before contacting the provider. Limits
count attempts in rolling minute and 24-hour windows. Waiting consumes no attempt.
Per-message SMTP 5xx and SES MessageRejected/InvalidParameterValue failures become
DEAD_LETTER, as do invalid MIME addresses or headers; transient failures
use the configured backoff. Explicit retry-after values are capped at one hour;
the configured backoff itself is not capped.
Expired attempts become LEASE_EXPIRED, then retry or dead-letter at the attempt limit.
Late completion from an expired runner cannot overwrite the newer attempt's outcome.
Delivery is **at least once**: provider acceptance followed by a runner crash can
produce a repeat. Credential, authentication and SES account failures immediately mark
the provider UNHEALTHY and stop its remaining deliveries for that dispatch pass.
These attempts are recorded as PROVIDER_FAILURE and keep messages in RETRY_WAIT with
backoff. They increment the historical attempt count but do not consume the message's
maxAttempts budget (including during lease recovery). Ten consecutive other transient failures mark a provider UNHEALTHY;
one queued message probes it every five minutes, and success restores HEALTHY.
Optional core alerts raise `email.provider.unhealthy` and `email.message.dead-letter`,
with dead-letter deduplication per provider/hour. No alert sink is required.
Alert failures cannot fail an already committed delivery.
SMTP sign-in requires STARTTLS or TLS. Plaintext authentication is refused at provider
validation and again before connecting, except for loopback (`localhost`, `127.0.0.1`,
`::1`) or an explicit `withAllowPlaintextSmtpAuth(true)` host configuration (default false).
Provider sender/reply-to fields and MIME subjects/addresses cannot contain CR or LF.
Messages offer standard Review/Result actions: Resend (DEAD_LETTER, BOUNCED,
COMPLAINED, with retained content), Cancel (QUEUED/RETRY_WAIT), and Send Now
(QUEUED/RETRY_WAIT). Resend creates a new linked message and rechecks suppression.
Send Now overrides deferral but preserves rate limits, health and active leases.
Providers offer Send Test Email with a recipient input and a reported outcome.
`email_send_templated` is a hidden backend API process. Inputs include `templateName`,
`to`, `cc`, `bcc`, `replyTo`, `subject`, `markdownBody`, `htmlBody`, `textBody`,
`providerName`, `brandName`, `category`, `locale`, `variablesJson`, `recordVariablesJson`,
`linkedTableName`, `linkedRecordId`, and `idempotencyKey`. `variablesJson` is an object;
record bindings use `{"patient":{"table":"patient","id":123}}` in
`recordVariablesJson`. Output includes `messageId`, `status`, and `reasons`.
Hidden schedules include `email_process_webhooks` (30 s), `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
recovery including disabled providers, and health probes), and `email_retention`
(daily). Retention clears final-message bodies after 30 days, preserving `bodySha256`;
message deletion is disabled by default. `finishedDate` records finalization independently
of enqueue and later retention updates.
## Delivery tracking
Apply the new `email_webhook_inbox` and `email_event` DDL plus indexes for an existing
phase-(a)/(b) database. Configure the authenticated HTTPS endpoints using
[the provider guide](docs/PROVIDERS.md#event-webhooks). Verification supports SNS
RSA v1/v2 with pinned topic and AWS certificate hosts, Postmark Basic auth,
SendGrid ECDSA and Mailgun HMAC with durable nonce replay protection.
The 1 MiB receiver persists before acknowledging. DB transactions serialize
processing across replicas and commit event rows, message state and suppression
atomically. Events in a payload are applied independently: matched events commit at
once, and only unmatched events (or events whose message is still SENDING) retry the
row with configured backoff. After six attempts they are skipped and the row finishes
PROCESSED with a note ("N events did not match a message"). Rows that cannot be parsed
or written fail after six attempts; **Reprocess** on FAILED inbox rows uses the
standard Review/Result flow. An event whose recipient is not on the matched message
is ignored, and an SES bounce or complaint without its recipient list is skipped.
SNS notifications older than an hour or more than five minutes ahead are refused, and
certificate fetch failures are cached for five minutes per URL. Disabled tracking is
ignored. Per-event recipient keys deduplicate
overlapping batches. All new service state is in the DB; certificate caches are in
memory. No runtime filesystem or new dependencies are used.
Hard bounce/complaint suppress ALL; provider unsubscribe suppresses the message's
opt-out category. Message status never regresses from COMPLAINED to BOUNCED or
DELIVERED. Optional `email.complaint` alerts warn after more than three complaints
in 24 hours, with daily deduplication. Provider details mask addresses and are capped
at 500 characters; raw authenticated inbox payloads are sensitive DB data.
NOTIFICATION and MARKETING messages with unsubscribe configured require exactly one
To recipient and no Cc/Bcc; send one message per recipient. The token binds that
mailbox, and an unsubscribe POST suppresses only that address for the message's
category. Multi-recipient requests are refused before suppression filtering.
TRANSACTIONAL and SECURITY messages, and NOTIFICATION messages without unsubscribe
configured, may still have multiple recipients. Existing single-recipient links
remain valid; old multi-recipient links are rejected.
## Core messaging bridge
The producer adds messaging provider `qbitEmail` (configurable), type `QBIT_EMAIL`.
Existing callers switch only `messagingProviderName`:
```java
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName("qbitEmail")
.withTo(new Party().withAddress("pat@example.com"))
.withSubject("Ready")
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Your export is ready.")));
```
The bridge recursively maps Party/MultiParty TO, CC, BCC, FROM and REPLY_TO roles, plus
TEXT/HTML alternatives. REFUSED throws `QException`; successful calls acknowledge
queueing, not delivery. The built-in EMAIL and SES types are unchanged.
For schema-aware calls from another qbit, use `EmailApi.send` with an
`EmailApiRequest` and declared template variables as described above. Ordinary
core `SendMessageInput` is ad-hoc and cannot infer classifications from arbitrary
pre-rendered text. The existing typed bridge extension is an internal interface.
## Configuration
All settings have fluent `withX` setters and getters.
| Setting | Default / purpose |
|---|---|
| `backendName` | Required existing backend |
| `schedulerName` | Optional existing scheduler; required for automatic dispatch |
| `publicBaseUrl` | Public app URL for signed unsubscribe links |
| `webhookBasePath` | `/qqq/hooks/email`; absolute path outside `/qqq/v1` |
| `mjmlCompiler` | Optional `MjmlCompilerInterface`; required for MJML activation |
| `classificationOverrides` | Map of `table.field` to `EmailClassifications.Level` for RC.5 |
| `defaultProviderName` | Optional named provider override; otherwise enabled default row |
| `bridgeProviderName` | `qbitEmail` |
| `secretResolver`, `allowedEnvNames` | `System::getenv`, empty additional allowlist |
| `dispatchIntervalSeconds`, `batchSize` | 15, 100 |
| `maxAttempts`, `retryMinutes` | 6; 1, 5, 15, 60, 240, 720 |
| `leaseSeconds` | 120; minimum 60 |
| `bodyRetentionDays`, `messageRetentionDays` | 30, 0 (keep message rows forever) |
| `appName`, `appLabel` | `email`, `Email` |
| `securityFields`, `recordSecurityLocks` | Empty host additions |
| `tableMetaDataCustomizer` | Optional final metadata customization |
`EmailIntegrations.esbTableNames()`, `apiTableNames()` and `quickSearchTables()` expose
host integration declarations for messages, templates and suppressions. Quick Search
weights exclude subjects, bodies and variable snapshots. Hosts wire their ESB/API/search
modules to these declarations.
## Build
```bash
./mvnw -o -q verify
```
Windows can use `./mvnw.cmd -o -q verify`. No new dependencies are required. Tests use
H2, GreenMail, a fake SES client and local HTTP servers, including transaction rollback, concurrent claims,
concurrent idempotency, shared rate budgets, health recovery and process screens.
The tests also exercise template lifecycle, parent-scoped possible values, rendering,
PHI canary egress, encryption, preview widgets and home data.
## Durable jobs
`EmailEffects.send(request, effectIdempotencyKey)` hands a message to the committed
email outbox exactly once per key and returns an `EmailEffects.Result` containing
`EmailEffectOutcome` and an `EmailMessageRef`. `QUEUED_OR_DUPLICATE` means the outbox
accepted responsibility; `REFUSED` means a definite refusal, including suppression.
Use `findByIdempotencyKey` or `findByRfcMessageId` to reconcile a handoff. These are
trusted system reads returning only references. Database failures propagate.
Requests must not carry a caller transaction; the effect dispatcher calls after its
own chunk commit. Existing `EmailService` transaction support is unchanged.
Each new message stores a unique `rfcMessageId` once. SMTP (including the Gmail preset),
Postmark, SendGrid and Mailgun carry it on every attempt. SES receives it in the raw
request but replaces it downstream; every transport retains `X-QQQ-Email-Id`.
Message-ID enables correlation and receiver deduplication where supported; it does
not promise exactly-once inbox delivery.
See [the handoff contract, provider references, schema upgrade and RC.7 binding sketch](docs/DURABLE-JOBS.md).
The build remains on released QQQ **4.1.0-RC.5**, without durable-jobs dependencies.
- [Getting Started](docs/00-getting-started.md)
- [Extension Patterns](docs/01-extension-patterns.md)
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
-4
View File
@@ -1,4 +0,0 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
-246
View File
@@ -1,246 +0,0 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. 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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
-19
View File
@@ -1,19 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+28
View File
@@ -0,0 +1,28 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleExtensionQBitProducer()
.withConfig(new ExampleExtensionQBitConfig()
.withTargetTableName("order"))
.produce(qInstance, "my-extension");
```
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
+16
View File
@@ -0,0 +1,16 @@
# Extension Patterns
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
```java
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
throw new QException("Target table not found: " + tableName);
}
new ExampleTableCustomizer().customize(table);
```
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
-581
View File
@@ -1,581 +0,0 @@
# qbit-email design
**Status:** phases (a), (b) and (c) implemented, except Preferences (8.3), deferred until core #1162 ships in an RC. Phase (d) consumer migrations and phase (e) inbound remain specification.
**Brief:** owner decisions of 2026-10-13 (providers, outbound first, both template kinds).
**Owner decisions (2026-10-12): all recommendations accepted.** Every "Recommended" choice and
every decision below stands; it is adjusted once it can be seen working.
One email service for a whole QQQ system. Every qbit and app that sends mail queues it through
this qbit's outbox and gets the same providers, templates, branding, tracking, suppression,
unsubscribe and PHI rules.
| # | Decision |
|---|---|
| D1 | Build on core's messaging SPI (`MessagingProviderInterface`, `SendMessageAction`). The qbit registers one provider type, `QBIT_EMAIL`, that writes to the outbox, so existing `SendMessageAction` callers move by pointing their `messagingProviderName` at it (section 2.4). Core's built-in `EMAIL` and `SES` types are not overridden. |
| D2 | Delivery transports live in the qbit (section 3): core's SMTP provider has no auth, STARTTLS or timeouts, and its SES provider uses AWS SDK v1 with static keys in metadata. No core PR is required; the transports can move to core later without changing the tables. |
| D3 | Providers are data rows (`email_provider`). Credentials are environment-variable NAMES, never values (the qbit-assistant rule). |
| D4 | Every send goes through the outbox (`email_message`); nothing sends inline. Async, at least once, retries with backoff, dead letters, idempotency keys. |
| D5 | Two template kinds: Markdown with `{{variables}}` inside the branded responsive layout (the default), and raw MJML or HTML (marketing). Templates are versioned. |
| D6 | Tracking events (SES via SNS, Postmark, SendGrid, Mailgun) arrive at `/qqq/hooks/email/...` (not the UI API), are signature-verified, and land in a durable inbox before processing. |
| D7 | Hard bounces and complaints suppress the address. One-click unsubscribe (RFC 8058) records a suppression for the message's category, and users see it as a Preferences section (core #1162). |
| D8 | PHI-safe by default: PII/PHI-classified variables are refused unless the template is `phi_approved`. The recommended PHI pattern is a "secure message" (a sign-in link, no PHI in the body). |
| D9 | Gmail: SMTP with an app password in phase (b), as a preset of the SMTP transport. The Gmail API with OAuth is specified (section 3.6) and built only on request. |
| D10 | MJML is compiled when a version is activated, by a pluggable compiler (default: the `mjml` CLI when configured, through stdin and stdout). Sends never need the compiler; HTML templates need nothing. |
| D11 | Owner rule (2026-10-13): no files or filesystem, only the database and S3. Attachments, rendered bodies and (phase e) inbound mail live in the database or in object storage (qbit-object-storage), never on local disk; the services are stateless, so any replica can send, receive a webhook or render a preview. |
## 1. Flow
```
EmailService.send(EmailRequest) ─┐
email_send_templated (process) ─┤
SendMessageAction (type QBIT_EMAIL) ─┼─► EmailOutbox.enqueue ─► email_message (QUEUED; rendered, redacted)
record action "Send Test Email" ─┘ idempotency key unique; suppression and PHI checks first
│
scheduler (every 15 s) ─► EmailDispatcher.runOnce
claim attempt n (unique insert into email_message_attempt)
─► provider rate limit ─► EmailTransport.send (SMTP | SES | Postmark | SendGrid | Mailgun | Gmail)
─► SENT (provider message id) │ RETRY_WAIT (backoff) │ DEAD_LETTER
│
provider webhook ─► /qqq/hooks/email/{kind}/{providerName} ─► verify ─► email_webhook_inbox (durable)
─► inbox processor ─► email_event (DELIVERED, BOUNCED, COMPLAINED, OPENED, CLICKED, ...)
─► message status ─► email_suppression (hard bounce, complaint)
List-Unsubscribe one-click POST ─► /qqq/hooks/email/unsubscribe/{token} ─► email_suppression (category)
```
## 2. Sending API
### 2.1 `EmailService` (internal Java enqueue service)
```java
EmailSendResult result = EmailService.send(new EmailRequest()
.withTemplateName("alerts_notification") // or withSubject + withMarkdownBody / withHtmlBody for ad-hoc mail
.withTo("pat@example.com") // withCc, withBcc, withReplyTo; "addr" or "Name <addr>"
.withVariable("count", 3) // plain values, checked against the template's schema
.withRecordVariable("order", "order", 1234) // a record, read as the sender; classification known
.withCategory(EmailCategory.NOTIFICATION) // default: the template's
.withProviderName("pmg") // optional; else the default provider
.withFromAddressId(4) // optional enabled email_provider_sender_address row of that provider
.withBrandName("kof22") // optional; else the template's, else the default brand
.withIdempotencyKey("alerts:notif:991") // optional; a repeat returns the existing message
.withLinkedRecord("alerts_alert", 991) // shown as a link on the message record
.withSender("qbit-alerts", "alerts_notify") // sender qbit and process
.withSendAfter(instant)); // optional deferral
// EmailSendResult { messageId, status (QUEUED | DUPLICATE | SUPPRESSED | REFUSED), reasons }
```
- Runs inside the caller's transaction when the backends match (the row commits with the caller's
write), else after commit, as qbit-webhooks does.
- Validation is synchronous: unknown template, missing required variables, bad addresses, a
PII/PHI variable on a template that is not `phi_approved` (section 7), and a disabled or missing
provider all return `REFUSED` with plain reasons, and nothing is queued.
- All recipients suppressed for the category: `SUPPRESSED`; the message is stored with that status
(visible in the outbox) and not sent. Partly suppressed: those addresses are dropped and listed
on the message.
### 2.2 Process `email_send_templated`
The same request as a backend process (template, to, cc, bcc, variables JSON, linked table and
id, idempotency key, provider by `providerId` or `providerName`, `fromAddressId` filtered by the
chosen `providerId`, brand) for scripts, the API and other qbits. Not in the nav;
callable through the API and `/qqq/v1/processes`.
### 2.3 Permissions
`email.view` (read every `email_` table), `email.send` (send through the API or the process;
system callers use the authorized scope), `email.manage` (templates, brands, suppressions, resend,
cancel), `email.admin` (providers, test send, PHI approval, removing complaint suppressions). A
template may set `edit_permission_name` to restrict who edits it (e.g. marketing templates to
`marketing.manage`).
### 2.4 Core messaging bridge
- `EmailOutboxMessagingProviderMetaData` (type `QBIT_EMAIL`, a `QMessagingProviderMetaData`)
registers `EmailOutboxMessagingProvider` with `QMessagingProviderDispatcher`. The producer adds
one instance named `qbitEmail` (configurable).
- `sendMessage(SendMessageInput)` maps the parties (`EmailPartyRole` TO/CC/BCC/FROM), subject and
contents (`EmailContentRole` TEXT/HTML) to an ad-hoc `EmailRequest` (no template, category
TRANSACTIONAL unless configured) and enqueues it. It throws on `REFUSED`, so callers still see
failures; delivery is then asynchronous.
- Attachments are stored through qbit-object-storage when it is installed, else refused with a
plain message (no blobs in the outbox, nothing on local disk: D11).
- Callers (qbit-alerts `EmailChannel` and `NotificationEmailer`, qbit-marketing
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailApi`
in phase (d) for templates, categories, idempotency and linked records.
### 2.5 Public API
`com.kingsrook.qbits.email.api.EmailApi` is the sole public qbit-email surface for consuming qbits.
Changes within a `0.x` minor release line are additive only. Public/protected
signatures contain only JDK, QQQ core or API-package types; internal packages are
not API and may change. A reflection test discovers every compiled API class and
checks signatures, including generic arguments, bounds and nested types.
- `EmailApi.send(EmailApiRequest)` maps to the existing outbox service, preserving
permissions, schema/PHI validation, suppression and one-recipient rules. Requests
cover recipients/sender selection, body alternatives, template variables and record
provenance, category/provider/brand/locale, idempotency, linked records, producer
identity, scheduling and caller transactions (`withTransaction(transaction, backendName)`).
Results expose message id, enqueue status and reasons.
- `sendOnce(EmailApiRequest, effectKey)` provides durable handoff through the existing
internal effects implementation. The key becomes the request's idempotency key;
null requests, missing/blank/over-500-character keys, mismatches and caller transactions
are refused. `EmailApiEffectResult` carries `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE`
or `REFUSED` and nullable `EmailApiMessage`. Success means committed outbox storage,
and replays retain the row and RFC Message-ID. Suppressed audit rows remain refused
on replay and return metadata; refusals without a row return null. Operational
failures throw `QException`. The deprecated `EmailEffects`, `EmailEffectOutcome` and
`EmailMessageRef` remain internal; consumers use this API. See [durable jobs](DURABLE-JOBS.md).
- `ensureTemplate(EmailApiTemplate)` delegates create-once template registration;
`EmailApiVariable` carries the variable contract. Existing content is never
overwritten, and the API does not confer PHI approval.
- `getMessage`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and
bounded, literal `findMessagesByIdempotencyKeyPrefix` perform
system reads of an explicit metadata projection: id/status/category, idempotency
key, RFC Message-ID and provider id, template name, linked record and create/sent/delivered dates.
Exact-key lookups return empty for null/blank/missing values. RFC lookup requires
the exact bracketed header stored at enqueue time, distinct from provider response ids.
No recipients, subjects, bodies or snapshots are fetched or returned by lookups.
Callers own authorization before presenting metadata to users.
- `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace
consumer access to runtime/configuration/tables. Readiness checks an enabled
default provider; MARKETING also needs a valid publicBaseUrl, available unsubscribe
key and default brand postal address. The PHI overload checks body key availability
and provider PHI/tracking policy. Reasons never contain resolved secret values.
PHI is a template state, so it is an explicit option rather than a fifth category.
- Database operations use the current `QContext`; an installed instance argument
must match that context. Database errors remain core `QException`. Readiness does
not validate an individual request or guarantee delivery.
For simple subject/body sends, consumers need no qbit-email dependency: use core
`SendMessageAction` with `messagingProviderName = "qbitEmail"` (or the host's
configured bridge name). Use the API package for templates, idempotency, links and
lookups. Both routes enqueue; neither sends inline.
## 3. Providers and transports
### 3.1 `email_provider`
| Field | Notes |
|---|---|
| name, label | unique name, used by `withProviderName` |
| kind | SMTP, SES, POSTMARK, SENDGRID, MAILGUN, GMAIL_SMTP, GMAIL_API |
| enabled, is_default | exactly one enabled default (validated) |
| from_address, from_name, reply_to | defaults for messages |
| sending_domain | the domain the provider is verified for; other from domains are refused |
| host, port, security | SMTP: NONE, STARTTLS, TLS; timeouts fixed (10 s connect, 15 s read and write) |
| region | SES region; Mailgun region (US or EU) |
| username_env_name, secret_env_name | env-var NAMES only (`^[A-Z_][A-Z0-9_]{0,99}$`, and in the config allowlist) |
| webhook_secret_env_name | env-var NAME for webhook verification (Mailgun signing key, SendGrid public key, Postmark basic-auth password) |
| sns_topic_arn, configuration_set, message_stream | SES events topic and configuration set; Postmark message stream |
| max_per_minute, max_per_day | counted from `email_message_attempt` in the database, so they hold across replicas |
| track_opens, track_clicks | off by default; refused for `phi_approved` templates |
| phi_allowed | admin attestation that the provider may carry PHI (BAA in place, or an internal relay) |
| health_status, last_success_date, last_failure_date, last_failure_message | kept by the dispatcher |
Secrets resolve through `EmailQBitConfig.secretResolver` (default `System::getenv`) at send time.
They are never stored, logged, audited, put in process output or returned. The record screen shows
whether each named variable is set (yes or no), never the value.
**Record action "Send Test Email"** (`email.admin`): queues a fixed test message to a given
address through that provider and reports the outcome on the standard result screen. An optional
`fromAddressId` picker lists only that provider's sender addresses.
**Sender addresses** (`email_provider_sender_address`, a child list on the provider, `email.admin`):
provider_id, address (validated, lowercase), display_name, is_default (at most one enabled default
per provider), enabled, note; unique (provider_id, address); the domain must equal `sending_domain`
when that is set. The provider's `from_address` / `from_name` remain the fallback; an enabled default
row wins when present. A chosen id, or an explicit from text once the provider has rows, must match an
enabled row of the resolved provider or the send is `REFUSED` ("That from address is not set up for
provider <name>"). The row used is kept as `email_message.from_address_id`.
### 3.2 `EmailTransport` SPI (in the qbit)
```java
public interface EmailTransport
{
EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException;
}
```
`EmailEnvelope` holds the rendered MIME parts: from, to, cc, bcc, reply-to, subject, HTML, text,
and headers (`List-Unsubscribe`, `Auto-Submitted`, `X-QQQ-Email-Id`). The result is the
provider's message id. The exception says whether the failure is **permanent** (rejected address,
sender refused, 4xx validation) or **transient** (connection, 5xx, throttling with an optional
retry-after).
### 3.3 SMTP (phase a)
`jakarta.mail` (already in core): auth when a username env name is set, STARTTLS or implicit TLS,
connect/read/write timeouts (the kof22 `Mailer` lesson: a stuck relay must not pin a thread).
Covers PMG (`smtp.galaxy.lan:26`, no auth), any relay, and Gmail SMTP.
### 3.4 SES (phase a)
AWS SDK v2 `sesv2` `SendEmail` with raw content, so our MIME and headers are kept. Credentials:
the default AWS chain when no env names are set (IRSA, instance role, `AWS_*`), else the named
access-key and secret env vars. The configuration set routes events to SNS (section 6).
### 3.5 Postmark, SendGrid, Mailgun (phase b)
HTTP JSON APIs through `java.net.http`, no vendor SDKs: Postmark `POST /email` (server-token
header, message stream), SendGrid `POST /v3/mail/send` (bearer key; `X-Message-Id`), Mailgun
`POST /v3/{domain}/messages.mime` (basic auth `api:key`, US or EU base). 429 and 5xx are
transient; other 4xx are permanent.
### 3.6 Gmail
- **Phase (b): `GMAIL_SMTP`**, a preset of SMTP (`smtp.gmail.com:587`, STARTTLS) with the account
as username and an app password env var. `docs/PROVIDERS.md` documents it: it needs 2-step
verification and an app password; Google's daily limits (about 500 for a personal account, 2000
for Workspace) go in `max_per_day`.
- **`GMAIL_API` (specified, built on request):** OAuth client id, secret and refresh token (env
names), `users.messages.send` with the raw MIME. It needs a Google Cloud project and consent
screen and gives system mail nothing the app password does not, so it is deferred.
## 4. Outbox and delivery
### 4.1 `email_message`
| Field | Notes |
|---|---|
| status | QUEUED, SENDING, SENT, DELIVERED, RETRY_WAIT, DEAD_LETTER, BOUNCED, COMPLAINED, SUPPRESSED, CANCELLED |
| to_addresses, cc_addresses, bcc_addresses, from_address, reply_to | addresses are PII; the table is classified PII (section 7) |
| subject | rendered; encrypted for `phi_approved` templates (section 7.3) |
| template_id, template_version_id, brand_id, category, locale | |
| variables_snapshot | JSON, redacted per section 7.3 |
| html_body, text_body | rendered at queue time so retries send the same bytes; encrypted for `phi_approved`; cleared after `bodyRetentionDays` (default 30) once final, keeping `body_sha256` |
| idempotency_key | unique when present |
| rfc_message_id | STRING(500), unique; stored once at enqueue and reused on every attempt; see [durable job effects](DURABLE-JOBS.md) |
| provider_id, provider_message_id | |
| attempt_count, next_attempt_date, lease_expires_date, last_error, sent_date, delivered_date, dead_letter_date | |
| linked_table_name, linked_record_id | rendered as a link on the record screen |
| sender_qbit, sender_process | |
| suppressed_addresses | addresses dropped at queue time |
Sub-tables, shown as relations on the message: `email_message_attempt` (message_id and
attempt_number unique as a pair, outcome, provider_id, claimed_by, start and end dates, duration,
error) and `email_event` (section 6).
### 4.2 Semantics (as qbit-webhooks, proven across replicas)
- **Claim:** to send attempt *n* a runner inserts (`message_id`, *n*) into
`email_message_attempt`; the pair is unique, so exactly one runner sends.
- **Lease:** the winner marks the message SENDING with a 120 s lease; an expired lease is retried
as attempt *n+1*.
- **At least once:** a runner that dies after the provider accepted, but before recording it,
causes at most one repeat. None of the providers offers a send-idempotency key, so the spec says
so plainly.
- **Outcomes:** success → SENT. Transient → RETRY_WAIT after `retryMinutes` (1, 5, 15, 60, 240,
720; retry-after honoured, capped at 1 h). Permanent → DEAD_LETTER at once. After `maxAttempts`
(6) → DEAD_LETTER.
- **Rate limits** per provider per minute and per day, from the database. Over a limit the message
waits; that is not an attempt.
- **Provider health:** HEALTHY → UNHEALTHY after 10 consecutive transient failures; while
unhealthy one message is tried every 5 minutes; a success → HEALTHY. Alerts (optional, section
9): `email.provider.unhealthy` (ERROR, resolved on recovery) and `email.message.dead_letter`
(WARNING, deduplicated per provider per hour).
- **Record actions:** Resend (dead letters and bounces: a new linked message with the same
content), Cancel (queued or waiting), Send Now (waiting). Standard streamed-ETL Review and Result
screens.
- **Retention:** a daily job clears bodies after `bodyRetentionDays` and deletes final messages
older than `messageRetentionDays` (0 = keep forever; kof22 keeps). NOTIFICATION and MARKETING
messages are never deleted, because their unsubscribe links resolve through the message row
(8.2); at `messageRetentionDays` retention only clears their bodies and variable snapshot.
## 5. Templates and branding
### 5.1 Tables
- `email_template`: name (unique, snake_case), label, category (TRANSACTIONAL, NOTIFICATION,
MARKETING, SECURITY), kind (MARKDOWN, MJML, HTML), brand_id, active_version_id, phi_approved,
phi_approved_by, phi_approved_date, edit_permission_name, owner_qbit, description.
- `email_template_version` (child list on the template): template_id, version_number, status
(DRAFT, ACTIVE, RETIRED), locale (default `en`), subject, preheader, body_source, text_source
(optional), compiled_html (MJML), variables_schema (JSON), sample_variables (JSON), created_by,
activated_date.
- `email_brand`: name, label, logo_url, logo_dark_url, logo_alt, primary_color, text_color,
background_color, dark_background_color, dark_text_color, button_text_color, footer_markdown,
postal_address (shown on marketing mail, for CAN-SPAM), social_links (JSON list of label and
url), site_url, is_default, app_name (optional: the brand for that app's sends).
An ACTIVE version cannot be edited. **Record action "New Version"** copies it to a DRAFT;
**"Activate Version"** validates it (schema, a render with the samples, the PHI check, MJML
compile, brand contrast) and makes it active, retiring the previous one. A message always records
the exact version it used.
### 5.2 Rendering
- **Variables:** `{{name}}` and dotted paths (`{{order.number}}`), always HTML-escaped;
`{{#if name}}…{{/if}}` and `{{#each items}}…{{/each}}`; `{{name|fallback text}}`. No unescaped
output and no helpers that run code. Unknown variables fail activation, not sends.
- **Schema:** each variable has name, label, type (STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL,
LIST, RECORD), required, an optional classification, and an optional bound `table` and `field`
(the classification then comes from the field, section 7).
- **Markdown** (commonmark-java with GFM tables and autolinks; raw HTML in Markdown disabled)
renders into the branded layout: a fixed, tested, table-based responsive shell (600 px max,
single column, inline styles plus one `<style>` block for media queries) with the logo, a hidden
preheader, the body, a button style for links written `[Label](url){.button}`, and the footer
(brand footer, postal address for MARKETING, the unsubscribe link when it applies).
- **Dark mode:** the layout declares `color-scheme: light dark` and `supported-color-schemes`,
switches to the brand's dark colours under `@media (prefers-color-scheme: dark)` (and the
`[data-ogsc]` hooks Outlook uses), uses the dark logo when set, never puts text over a
background image, and keeps text and button contrast at WCAG AA in both schemes. Activation
checks the brand colours and refuses a brand that fails, quoting the ratio.
- **MJML:** compiled at activation by `MjmlCompilerInterface` (default `CliMjmlCompiler`, which
runs the configured `mjml` binary with a timeout). With no compiler configured, MJML versions
cannot be activated, and say so. The compiled HTML is stored on the version.
- **HTML:** used as given; variables are still substituted and escaped. The qbit adds the
unsubscribe footer and headers for MARKETING and NOTIFICATION.
- **Plain text** is generated from the rendered HTML (links as `text (url)`, headings and lists
kept) unless the version has its own text.
- **i18n-ready:** versions carry a locale; `EmailRequest.withLocale` picks that locale's active
version, else the default locale's. One locale (`en`) ships.
### 5.3 Preview
**Part 2 status:** standard record widget implemented. Next's sanitizer removes
iframes and style blocks, so it uses sanitized inline light (390px maximum) and dark
(600px maximum) samples, with wrapping plain text. Draft MJML previews compile in memory
with the configured compiler; stored versions are unchanged. TODO: endpoint arrives with RC.6.
Each version record shows a **Preview** widget: the rendered email with the sample variables in a
sandboxed iframe (`srcdoc`, scripts disabled), Light/Dark and 390/600 px toggles, and the plain
text. It is served through the UI API (section 10): `POST /qqq/v1/qbit/email/preview` (a version
id, or unsaved source, plus variables) returns subject, html, text and warnings.
### 5.4 Part 2 implementation choices
- Approval records `approved_version_id` on the template. An approved DRAFT freezes;
approve a new sensitive version before activation. Activating a different,
unapproved version clears approval. Approval is specific to one locale version.
- `EmailBuiltIns.ensure()` seeds `email_secure_message` on its first send, using the
database unique name and a transaction, with no static bootstrap cache.
- `dark_primary_color` and `dark_button_text_color` optionally override their light
counterparts; both button pairs are contrast-checked. Brand colors use #RRGGBB.
- `EmailTemplatedMessageInput` extends core SendMessageInput for schema variables and
record references. Ordinary core input remains compatible ad-hoc content.
- RC.5 QueryAction masks passwords but does not apply ordinary FieldSecurityLock;
record-variable loading additionally applies that lock to sender session keys.
- The real outbox id is bound during the enqueue transaction, before commit. Failed
validation persists no message. Resends preserve encrypted content and its original
token snapshot, linking to the original message for the token binding.
- The home follows the newer Alerts pattern: standard QQQ chart widgets plus inline,
theme-neutral cards. All queries/counts use the viewer's session. Bounce/complaint
dates are populated by phase-(c) events; home totals count those recipient events.
- Part 2 signs opt-out links and prepares headers; phase (c) now serves the receiver. Without its key/base URL, notification omits opt-out links and
headers, while marketing refuses as specified in 8.2. Do not use opt-out delivery
without a receiving handler. The token's address hash binds the single recipient mailbox.
## 6. Delivery tracking
### 6.1 Inbound route
`EmailWebhookRouteProvider` (a `QJavalinRouteProviderInterface`, registered idempotently as in
qbit-easypost-tracking) serves `POST /qqq/hooks/email/{kind}/{providerName}`: size cap (413), find
the enabled provider of that kind (404), verify (401 on failure, 503 when its secret is not set),
dedup-insert the raw payload into `email_webhook_inbox` (provider_id and external_event_id unique,
status PENDING, attempts, payload_json, payload_sha256, received_date) and answer 200. A scheduled
process drains the inbox into `email_event`.
| Kind | Verification |
|---|---|
| SES (SNS) | SNS message signature (SignatureVersion 1 and 2) with the certificate from `SigningCertURL`, which must be `https://sns.<region>.amazonaws.com/...pem`; the topic ARN must be the provider's. `SubscriptionConfirmation` is confirmed by fetching `SubscribeURL` (same host rule). |
| Postmark | HTTP basic auth on the webhook URL (Postmark does not sign); password from the webhook secret env name, compared in constant time. |
| SendGrid | Signed Event Webhook: ECDSA P-256 over timestamp + body with the public key from the env name; timestamp within 5 minutes. |
| Mailgun | HMAC-SHA256 of timestamp + token with the signing key; timestamp within 5 minutes; a token seen before is refused. |
### 6.2 `email_event`
message_id (matched by provider message id or our header), provider_id, event_type (DELIVERED,
DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED), bounce_type (HARD, SOFT,
UNDETERMINED), recipient, occurred_date, detail (the provider's reason, truncated; never body
content), url (clicks only). Opens and clicks are stored only when the provider row enables them.
### 6.3 Phase (c) implementation
- Inbox states: PENDING, PROCESSED, FAILED, IGNORED. Retry after configured backoff,
FAILED after six attempts, and a standard Reprocess action resets FAILED to PENDING.
- Events in one payload are applied independently. Only unmatched events (or events whose
message is still SENDING) defer the row; on the sixth attempt they are skipped and the row is
PROCESSED with the note "N events did not match a message". Suppression applies only to a
recipient in the matched message's to, cc or bcc; an SES bounce or complaint without its
recipient list is skipped, never widened to all destinations. SNS notifications must carry a
Timestamp no older than 1 hour and no more than 5 minutes ahead, and certificate fetch
failures are cached for 5 minutes per URL.
- A short DB transaction locks the common provider gate then the actual provider;
it rechecks the observed inbox attempt and commits all changes together. No worker
filesystem, extra claim table, in-process scheduler or network operation is needed
during processing. A crash rolls back the transaction and releases the claim.
- `email_event.event_key` provides unique recipient/event deduplication across
overlapping batches, alongside inbox uniqueness on provider/event and provider/nonce.
- Message correlation always stays within the authenticated provider. Unknown message
ids retry (including callbacks before SENT); unsupported event kinds are ignored.
- Opens/clicks require current provider flags and are also dropped for PHI-approved
messages. Event details mask addresses before truncation to 500 characters.
- The unsubscribe signer binds one mailbox. A valid POST suppresses only that address
for MARKETING/NOTIFICATION; the page never displays addresses. Legacy links remain
valid only for messages with one To recipient and no Cc/Bcc.
- PostgreSQL and H2 definitions, crypto fixtures and real random-port Javalin tests
are included. Certificate caching is memory-only. Setup is in `PROVIDERS.md`.
## 7. PHI and PII
### 7.1 Classification
Variables bound to a field (schema `table` + `field`, or `withRecordVariable`) take that field's
classification from core `AiDataClassifications.ofField` (`DataClassification` PUBLIC,
CONFIDENTIAL, PII, PHI; core #1142, first in 4.1.0-RC.6). Until RC.6 the qbit uses a stand-in,
`EmailClassifications` (reflective lookup of the core class, else config-declared
classifications), marked for replacement. Unbound variables may be declared PII or PHI in the
schema.
### 7.2 Refusal
- Activation and sending refuse a template whose variables include PII or PHI unless it is
`phi_approved`. Only `email.admin` sets that, through the record action "Approve For PHI", which
records who and when; a new version needs approval again.
- A `phi_approved` template may not use open or click tracking, may not be MARKETING, and must go
through a provider marked `phi_allowed`.
- **The default PHI pattern** is the built-in template `email_secure_message`: "You have a new
secure message from {{brand.label}}. Sign in to read it." with a button to the link the caller
passes. No PHI in the body, so it needs no approval. Consumers that hold PHI use it.
### 7.3 Redaction and storage
Following core `docs/SECURED-DATA.md` (section 15, QRun-IO/qqq#1161):
- `variables_snapshot`: PII/PHI values become tokens in the format of core
`ClassifiedValueTokens` (package `...core.actions.permissions.fieldaccess`, SECURED-DATA §14.5):
`hmac:v1:<keyId>:<b64url(HMAC-SHA256(key, m))>`, with `m` joined by `` and the canonical
value last, and the instance key from `QQQ_AUDIT_HMAC_KEY` (base64).
- **Record-bound variables** use the record binding, `m = table␟field␟pk␟canonical(value)`
(`forRecordField`), so the token matches the audit's and a field under a wipe rule gets
`shred:v1` with the same per-record-field key: a wipe also shreds the outbox snapshot.
- **Other values** use a domain-prefixed binding,
`m = "email"␟template␟variable␟messageId␟canonical(value)` (`forContext`), so they never
collide with a record binding. They get `hmac:v1` only; a low-entropy value (booleans, values
with under 4 distinct characters) gets `changed:v1`, because there is no record key to shred.
- No key configured: `changed:v1`.
- Until core ships the helper, the qbit carries `EmailValueTokens`, marked
`STAND-IN(core=#1161-impl, removeBy=RC.7)`, with the same names and output.
- The subject and bodies of `phi_approved` messages are stored AES-256-GCM encrypted with a key
from `QQQ_EMAIL_BODY_KEY` (no key: `phi_approved` sends are refused) and cleared after
`bodyRetentionDays` once final.
- Logs carry the message id and status only, never addresses, subjects or bodies.
- Record variables are read as the sending user, through core masking, so a value the sender may
not see arrives masked.
- **Canary-PHI egress test:** plants `CANARY-PHI-<random>` in a PHI field, sends through every
path (EmailService, the process, the core bridge, retry, dead letter, webhook events, an export
of `email_message`, the home widget, captured logs) and fails on any substring of the canary
longer than 4 characters outside the encrypted columns. It follows the core procedure so it can
join the shared canary suite when that exists.
## 8. Suppression and unsubscribe
### 8.1 `email_suppression`
address (lowercased), scope (ALL or a category), reason (HARD_BOUNCE, COMPLAINT, UNSUBSCRIBE,
MANUAL), source_message_id, source_event_id, note, created_date, expires_date (optional). Unique on
(address, scope). `email.manage` adds and removes rows; removing a COMPLAINT needs `email.admin`.
- Hard bounce → ALL. Complaint → ALL. Unsubscribe → the message's category.
- TRANSACTIONAL and SECURITY mail ignores UNSUBSCRIBE rows (they cannot be opted out of) but
respects HARD_BOUNCE and COMPLAINT.
### 8.2 One-click unsubscribe (RFC 8058, RFC 2369)
- NOTIFICATION or MARKETING messages carrying an unsubscribe link/header must have exactly one To recipient and no Cc/Bcc; send one message per recipient.
- MARKETING and NOTIFICATION messages carry
`List-Unsubscribe: <https://{publicBaseUrl}/qqq/hooks/email/unsubscribe/{token}>` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and the footer links to the same URL.
- The token is `v1.<b64url(payload)>.<b64url(HMAC-SHA256)>` over the message id, an address hash
and the category, keyed by `QQQ_EMAIL_UNSUBSCRIBE_KEY`. With no key there is no header and
MARKETING sends are refused. Tokens do not expire (RFC 8058 expects the link to keep working).
- `POST` with `List-Unsubscribe=One-Click` records the suppression and returns 200. `GET` returns a
minimal page with one "Unsubscribe" button that POSTs, because mail scanners prefetch GETs; then
"You are unsubscribed from {category}". A Preferences link awaits core #1162.
- An `UNSUBSCRIBED` event is recorded on the message.
### 8.3 Preferences (core #1162)
**Deferred: core #1162 is not in an RC.** The planned qbit registers a Preferences section `email` ("Email") whose provider
(`EmailPreferenceProvider implements UserPreferenceProviderInterface`) maps the signed-in user's
address (`UserPreferenceRequestContext.getUserEmailAddress()`, never an address from the request)
to `email_suppression` rows: one toggle per opt-out-able category (NOTIFICATION,
MARKETING) and read-only rows saying that account and security mail is always sent. Qbits with
finer settings keep their own sections: qbit-alerts' "Notifications" decides which alerts are
emailed; this section decides whether NOTIFICATION mail reaches the address at all. It is built
once #1162 is in an RC; one-click unsubscribe does not depend on it. With no address on the
account, the rows are read-only ("No email address on your account") and writes are refused. The
section's description explains the split with qbit-alerts' Notifications section.
## 9. Integrations
- **Alerts (core `QAlerts`, #1108):** called reflectively when present (section 4.2).
- **ESB and Quick Search:** `email_message`, `email_template` and `email_suppression` publish table
events and are searchable (`EmailIntegrations`); bodies and snapshots are excluded.
- **API:** the same tables, permissions and customizers as the admin (QQQ API middleware).
- **Object storage (#1089):** attachments (section 2.4) and, in phase (e), inbound attachments.
## 10. App and UI
- App **Email**, with one home dashboard (one HTML widget, real data only, inline styles, no inner
or horizontal scroll, stacked cards under 640 px, light and dark):
- sent, delivered, dead letters, bounced and complained in the last 24 h and 7 days;
- sends per hour for 24 h (sent and failed);
- suppression growth (new rows per day for 30 days, by reason);
- provider health (status, last success, last failure reason, rate-limit headroom);
- recent messages (newest 20: when, recipient masked as `p***@example.com`, subject unless
`phi_approved`, status), each linking to its record.
- Nav: Messages, Templates, Brands, Suppressions, Providers. Attempts, events, versions and inbox
rows are relations on their parents.
- UI API extension `email` (1.0.0, `/qqq/v1/qbit/email/...`): `POST /preview` (section 5.3), with
its OpenAPI fragment committed and contract-tested. Everything else is standard tables,
processes and widgets. The webhook and unsubscribe routes are non-UI (`/qqq/hooks/email/...`).
## 11. Consumers (phase d, one PR per repo)
| Consumer | Change |
|---|---|
| qbit-alerts | `EmailChannel` and `NotificationEmailer` use `EmailService` with templates `alerts_notification` and `alerts_digest` (NOTIFICATION), idempotency `alerts:<id>`, linked alert. `messagingProviderName` stays as the fallback when qbit-email is absent. |
| qbit-collaboration | an `EmailCollaborationNotifier` for mentions, shares and task reminders (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`); record labels obey `isRecordLabelFullAccess`. |
| qbit-assistant | scheduled-task results by email through `EmailService` (template `assistant_scheduled_result`), after its EXTERNAL approval rule. |
| qbit-easypost-tracking | delivery notices through a notifier that sends `easypost_delivery_notice` (NOTIFICATION). |
| qbit-marketing | a `MarketingTransport` over `EmailService` (MARKETING, MJML/HTML templates, unsubscribe headers, suppression honoured). |
| kof22 (Website-Backend) | the contact form's `Mailer` is replaced by `EmailService` with template `kof22_contact`, through an SMTP provider row for PMG (`smtp.galaxy.lan:26`, no auth, no new secrets). A draft PR with a Liquibase changeset for the `email_` tables and seed rows. |
| Authentik | **keeps its own SMTP to PMG.** It is a separate product (Python) with its own flows and templates (recovery, enrollment, MFA); it must send when the QQQ app is down, since it guards sign-in to that app; and it has no hook to call a Java service. Its mail is account and security mail, to which suppression-by-unsubscribe would not apply anyway. |
## 12. Phase 2: inbound (design only)
- Sources: SES inbound (receipt rule → S3 → SNS → `/qqq/hooks/email/inbound/ses/{name}`), Mailgun
routes, Postmark inbound, SendGrid inbound parse, and an IMAP or Gmail mailbox poller
(scheduled).
- `email_inbound`: provider, message id, in_reply_to, references, from, to, subject, text and html
(sanitized), received_date, status (NEW, ROUTED, UNMATCHED, REJECTED), routed_table, routed_id,
spam verdicts. Attachments go to qbit-object-storage (`email_inbound_attachment` rows).
- **Reply-to-record threading:** outbound NOTIFICATION mail sets
`Reply-To: reply+<token>@<inbound domain>`, where the token (HMAC) names the message and its
linked record. A reply is matched by token; the sender must be the original recipient; quoted
text is stripped; an `InboundEmailHandler` registered by the linked table's qbit acts (e.g.
qbit-collaboration adds a comment as that user). Unmatched mail stays UNMATCHED for review.
- PHI: inbound bodies are classified PHI by default (unknown content) and stored encrypted.
## 13. Data model summary
| Table | In the nav | Written by |
|---|---|---|
| `email_message` | yes | the qbit (people: record actions only) |
| `email_message_attempt` | relation | the qbit |
| `email_event` | relation | the qbit |
| `email_template` | yes | people (`email.manage`, per-template permission) |
| `email_template_version` | relation | people (drafts), actions (activation) |
| `email_brand` | yes | people |
| `email_suppression` | yes | people and the qbit |
| `email_provider` | yes | admins |
| `email_provider_sender_address` | relation (on the provider) | admins |
| `email_webhook_inbox` | relation (on the provider) | the qbit |
| `email_inbound`, `email_inbound_attachment` | phase (e) | the qbit |
Postgres DDL in `docs/ddl/email-postgres.sql` (phases a-c).
## 14. Configuration (`EmailQBitConfig`)
backendName (required), schedulerName (required for automatic processing), publicBaseUrl (unsubscribe links),
webhookBasePath (`/qqq/hooks/email`, shared by tracking and unsubscribe),
defaultProviderName, bridgeProviderName (`qbitEmail`), secretResolver, allowedEnvNames,
dispatchIntervalSeconds (15), batchSize (100), maxAttempts (6), retryMinutes, leaseSeconds (120),
bodyRetentionDays (30), messageRetentionDays (0), mjmlCompiler, classificationOverrides, appName
and appLabel, securityFields and recordSecurityLocks (multi-tenant hosts).
## 15. Phases and acceptance
| Phase | Scope | Acceptance (tests) |
|---|---|---|
| (a) | tables, outbox and dispatcher, SMTP and SES, Markdown/HTML/MJML templates and versions, brands and the layout, preview, EmailService, the process, the core bridge, PHI refusal and redaction, the app home | GreenMail SMTP sink end to end; SES through a mocked `SesV2Client`; the claim race (two runners, each message sent once); retry, dead letter, idempotency; rendering (variables, escaping, `#if`/`#each`, plain text, dark-mode contrast); PHI refusal; snapshot redaction; the canary test; producer standards (names, nav, permissions) |
| (b) | Postmark, SendGrid, Mailgun, Gmail SMTP | HTTP mocks per provider (success, 4xx permanent, 429/5xx transient); the Gmail preset. HTML/MJML moved into part (a), part 2 at owner request. |
| (c) | webhook route and inbox, events, suppression, unsubscribe (Preferences deferred) | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
| (d) | consumer migrations | each consumer's own tests; a contract test of `EmailService` |
| (e) | inbound | design only for now |
Dependencies: phase (a) builds on QQQ 4.1.0-RC.5 with the classification stand-in, and moves to
later RCs for `DataClassification` and Preferences when their APIs are released.
## 16. Owner steps (outside code)
- **PMG (kof22):** none; the relay already accepts the cluster.
- **SES (when a consumer adopts it):** create the SES identity for each sending domain in the
`terraform/dns-route53` root (owner-applied), request production access, and create a
configuration set with an SNS destination to `https://<app>/qqq/hooks/email/ses/<provider>`.
The DNS records (DKIM CNAMEs, a custom MAIL FROM subdomain with its MX and SPF; DMARC stays at
p=reject) go in as Praetor octoDNS PRs once the identity exists, because SES issues the DKIM
tokens.
- **Postmark, SendGrid, Mailgun, Gmail:** accounts and keys only if used; keys go into Infisical
under the env names the provider rows reference.
- **Keys:** `QQQ_EMAIL_UNSUBSCRIBE_KEY` and `QQQ_EMAIL_BODY_KEY` per deployment (Infisical, seeded
write-only like other app keys).
-129
View File
@@ -1,129 +0,0 @@
# Durable job email effects
This implements the email-side contract for QRun-IO/qqq#1191, spec section 2.13,
without importing core jobs types. QQQ stays at released 4.1.0-RC.5. The
`EmailJobEffectTarget implements EffectTarget` binding is deferred until RC.7 ships.
## Stored identity and schema upgrade
`email_message.rfc_message_id` (`rfcMessageId` in metadata) is a unique STRING(500)
shown in the message T2 details. Before insertion, the service stores
`<token@domain>`, where `token` is the first 32 lowercase hex characters of SHA-256
of the UTF-8 idempotency key. Without a key, the seed is `qbit-email:` plus a fresh
UUID, since the row id is not yet allocated. The domain is the normalized From
mailbox domain, excluding display names; unavailable or unsuitable domains use
`qbit-email.local`. The key itself is never included in the header.
The dispatcher reads this stored value on every attempt. JavaMail's
`updateMessageID()` is overridden so repeated `saveChanges()` calls cannot replace
it. A manual **Resend** creates a new outbox row and a new identity; a delivery retry
keeps its identity. `providerMessageId` remains the provider's response identifier,
distinct from `rfcMessageId` and the numeric outbox `id`.
New installations use [email-postgres.sql](ddl/email-postgres.sql). Existing
PostgreSQL installations must pause enqueue and dispatch, then apply
[email-message-id-upgrade.sql](ddl/email-message-id-upgrade.sql) before starting
this version. The migration backfills old rows using the existing key, or
`qbit-email:` plus the already allocated row id, and the stored From domain.
It cannot recover previously generated random transport Message-IDs. Review host
schema adaptations first; host security columns are unaffected.
## Provider behavior
| Transport | Request mechanism | Identity preservation |
|---|---|---|
| SMTP / Gmail SMTP preset | MIME `Message-ID` | Sent unchanged by this transport; downstream relay policy still applies |
| SES v2 | Raw MIME `Message-ID` | **SES replaces Message-ID downstream** |
| Postmark | `Headers: [{Name: "Message-ID", Value: "<...>"}]` | Custom Message-ID supported |
| SendGrid | `headers: {"Message-ID": "<...>"}` | Sent as a custom header |
| Mailgun | MIME Message-ID plus multipart `h:Message-Id` | Sent in the `/messages.mime` request |
All transports retain `X-QQQ-Email-Id` with the numeric outbox id. Postmark's
`MessageID`, SendGrid's response `X-Message-Id`, and SES's returned id are provider
references, not our RFC header. A fixed header enables recognition and receiver
deduplication where supported, but is not an exactly-once inbox guarantee.
Provider references: [Postmark custom Message-ID](https://postmarkapp.com/developer/user-guide/send-email-with-api/send-a-single-email),
[SendGrid custom headers](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send),
[Mailgun sending parameters](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages),
and [SES header replacement](https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html).
## Handoff and reconcile
`EmailApi.sendOnce(EmailApiRequest request, String effectKey)` sets the request's key, refusing
a different pre-existing key, a missing/blank/over-500-character effect key, a null request
or a caller transaction. The service performs its usual validation and send
permission checks. Success is returned only after the outbox transaction commits;
there is no transport call here. Use ordinary `EmailApi.send` with
`request.withTransaction(transaction, backendName)` for caller-owned or cross-backend
deferred transactions. `sendOnce` refuses either kind of caller transaction.
`EmailApiEffectResult(outcome, message)` contains an `EmailApiEffectOutcome` and a
nullable `EmailApiMessage` with the outbox id, current delivery status, RFC Message-ID,
provider reference and other metadata. All these types are in
`com.kingsrook.qbits.email.api`, the sole public qbit-email surface:
- `QUEUED_OR_DUPLICATE`: one committed row accepted the handoff, including replays.
- `REFUSED`: invalid or suppressed request. A suppressed audit row has a reference;
refusals without a stored row have a null reference. Replaying a suppressed row
stays refused even though `EmailService` returns `DUPLICATE`.
- Database/query failures throw `QException`; callers must retry or reconcile,
never turn an uncertain outcome into a permanent refusal.
`EmailApi.getMessageByIdempotencyKey(key)` and `EmailApi.getMessageByRfcMessageId(messageId)`
return `Optional<EmailApiMessage>` from system reads of an explicit metadata projection.
They return empty for null/blank/missing values. These are trusted backend APIs,
not viewer-scoped public endpoints. Lookups reflect current status and provider
reference and do not expose bodies, recipients or variables. `EmailApi.getMessage(id)`
and `EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, limit)` return the same projection,
including `rfcMessageId`. Callers must authorize exposing this metadata to their users.
`EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internal
implementation types; consuming qbits use `EmailApi`.
The effect's `SENT` means successful durable handoff, not mailbox delivery. The
email dispatcher owns later delivery/retry failures. Reconcile acknowledges an
existing outbox row (including an audit row); it must never create another email
merely because that row is terminal. Retain message rows and idempotency keys for
at least the maximum effect retry/reconciliation horizon.
## RC.7 binding design note (not compiled)
Mapping: `deliver -> QUEUED_OR_DUPLICATE => SENT(ref.id)`, `REFUSED => FAILED`;
`reconcile -> getMessageByIdempotencyKey present => FOUND(ref.id), absent => NOT_FOUND`.
Exceptions escape to core's retry/unknown handling. Payload decoding must preserve
the existing PHI/variable policies and must not attach a caller transaction.
This 25-line sketch is documentation-only. Match core names and signatures to the
published RC.7 contract when it ships; `decode` represents payload conversion.
```java
// Future RC.7 binding; no core jobs imports in the RC.5 implementation.
final class EmailJobEffectTarget implements EffectTarget
{
// SENT acknowledges the committed qbit outbox row.
@Override
public DeliveryResult deliver(Effect effect) throws Exception
{
EmailApiRequest request = decode(effect.payload());
EmailApiEffectResult result = EmailApi.sendOnce(request, effect.idempotencyKey());
if(result.outcome() == EmailApiEffectOutcome.REFUSED)
{
return (DeliveryResult.failed("Email refused"));
}
return (DeliveryResult.sent(result.message().id().toString()));
}
// Existence reconciles handoff independently of mailbox delivery.
@Override
public ReconcileResult reconcile(Effect effect) throws Exception
{
return (EmailApi.getMessageByIdempotencyKey(effect.idempotencyKey())
.map(ref -> ReconcileResult.found(ref.id().toString()))
.orElseGet(ReconcileResult::notFound));
}
}
```
Tests use H2, the existing GreenMail SMTP sink, fake SES and HTTP request seams;
no vendor endpoints are called. The PostgreSQL migration is supplied for host
deployment and is not executed by the offline Java test gate.
-265
View File
@@ -1,265 +0,0 @@
# Email providers
Create an enabled `email_provider` row and choose exactly one enabled default.
Provider edits and Send Test Email require `email.admin`. Test sends queue through
the outbox and obey the same rate limits as application messages.
Field names here are database columns. QQQ record/API names are camelCase:
`secret_env_name` is `secretEnvName`, for example. All kinds use `name`, `label`,
`kind`, `enabled`, `is_default`, `from_address`, optional `from_name` / `reply_to`,
and optional `max_per_minute` / `max_per_day`. When set, `sending_domain` must
match the From address domain exactly.
## Credentials and delivery
Credential fields contain environment variable **names**, never values. Names
must match `^[A-Z_][A-Z0-9_]{0,99}$` and the effective allowlist. The defaults
below supplement `EmailQBitConfig.allowedEnvNames`; explicitly allow custom names.
Allowlisting does not automatically select a name for a row.
| Kind | `username_env_name` | `secret_env_name` | `webhook_secret_env_name` |
|---|---|---|---|
| SMTP | `EMAIL_SMTP_USERNAME` (optional) | `EMAIL_SMTP_PASSWORD` | Not used |
| SES | `AWS_ACCESS_KEY_ID` (optional) | `AWS_SECRET_ACCESS_KEY` (optional pair) | Not used; `sns_topic_arn` is a plain ARN |
| POSTMARK | Not used | `POSTMARK_SERVER_TOKEN` | `POSTMARK_WEBHOOK_PASSWORD` |
| SENDGRID | Not used | `SENDGRID_API_KEY` | `SENDGRID_WEBHOOK_PUBLIC_KEY` |
| MAILGUN | Not used | `MAILGUN_API_KEY` | `MAILGUN_WEBHOOK_SIGNING_KEY` |
| GMAIL_SMTP | `GMAIL_USERNAME` | `GMAIL_APP_PASSWORD` | Not used |
Legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD`,
`EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY`, `EMAIL_MAILGUN_API_KEY`,
`EMAIL_MAILGUN_SIGNING_KEY`, `EMAIL_GMAIL_USERNAME` and `EMAIL_GMAIL_APP_PASSWORD`
remain allowlisted for their kinds. SES also allowlists `AWS_SESSION_TOKEN` for
the default credential chain; explicit row credentials are an access/secret pair.
Values resolve through `secretResolver` (default `System::getenv`) for each send
attempt. Rotation needs no service restart. Provider record presence indicators
also resolve values, returning only Set / Not set; enqueue and internal queries
do not resolve credentials. Values and provider response diagnostics are never
logged, stored in errors, returned in exceptions or cached by these transports.
HTTP uses fixed HTTPS endpoints, a 10-second connect timeout, a 30-second request
timeout, no redirects and no client retries. The outbox owns retries. MIME and
multipart bodies are built entirely in memory; services use no filesystem storage.
HTTP 429 and 5xx are transient. Retry-After accepts seconds or an HTTP date;
the dispatcher caps explicit delays at one hour. Authentication/account failures
mark the provider UNHEALTHY, stop its remaining deliveries for the dispatch pass,
and retain messages for retry without consuming their failure budget. Permanent
message errors become DEAD_LETTER. Exceptions use fixed text and numeric codes;
stored errors use fixed messages, never response bodies.
Acceptance means SENT, not delivered. Delivery is at least once: a crash after
acceptance can cause a duplicate. Limits count attempts across replicas in rolling
minute and 24-hour windows, not recipients or successful deliveries. Leave room
for retries, recipient quotas and other applications using the same account.
## SMTP
Set `kind=SMTP`, `host`, `port`, and `security=NONE`, `STARTTLS`, or `TLS`. For
authenticated SMTP, set both credential names; for an unauthenticated relay,
leave both empty. Authentication requires encryption except for loopback or an
explicit host `allowPlaintextSmtpAuth` configuration.
Configure the relay's allowed sender domains and route. For public delivery,
verify the domain with the operator, publish its DKIM and SPF records, and align
the From domain with DMARC. Update the existing SPF policy rather than adding a
second SPF record. Set limits below the relay's quota. SMTP has 10-second connect
and 15-second read/write timeouts; there is no provider event webhook here.
## Amazon SES
Set `kind=SES`, `region`, and a verified `from_address`. Optionally set
`sending_domain`, `configuration_set` and `sns_topic_arn`. Leave credential names
empty for the AWS default chain (workload/instance role or AWS environment);
otherwise supply both access-key and secret-key environment names.
Verify the sender identity in the sending region and publish SES's DKIM records.
Configure SPF for a custom MAIL FROM domain if used. Request production access
when leaving the sandbox. Configure the configuration set's SNS event destination
and put the topic ARN in `sns_topic_arn`, not a secret field.
Sending quotas are regional and account-specific, with sandbox, recipient and
rate restrictions. Set both rate fields to fit those quotas. SES sends raw MIME
with explicit envelope destinations and strips Bcc from its MIME headers.
## Postmark
Set `kind=POSTMARK`, `secret_env_name=POSTMARK_SERVER_TOKEN`, a verified
`from_address`, and optionally `sending_domain`. Use a server token, not an
account token. `message_stream` defaults to `outbound`; another transactional or
broadcast stream must exist on that server. `track_opens` and `track_clicks`
default to false; click tracking maps to HtmlAndText or None.
Verify the domain or sender signature, publish Postmark's DKIM and recommended
Return-Path/SPF DNS records, obtain account approval and create the desired
message stream. The endpoint is `https://api.postmarkapp.com/email`;
the returned `MessageID` is retained.
| Signal | Outcome |
|---|---|
| ErrorCode 10 | Provider failure: bad token |
| ErrorCode 300 / 406 | Permanent: invalid request / inactive recipient |
| ErrorCode 400/401, 405, 412, 413, 422 | Provider failure: signature, sending approval or server/account configuration |
| ErrorCode 1235/1236 / 1480 | Provider failure: stream / IP configuration |
| HTTP 401/403 | Provider failure |
| HTTP 429 / 5xx | Transient; takes precedence over a body code |
| Other request errors | Permanent; malformed successful responses are transient |
API ErrorCode 413 is an account problem; HTTP 413 is a payload-size error.
Check plan, recipient and payload limits before setting the rate fields. The
single-message API documents a 10 MB message limit. See
[Postmark API errors and limits](https://postmarkapp.com/developer/api/overview).
## SendGrid
Set `kind=SENDGRID`, `secret_env_name=SENDGRID_API_KEY`, a verified `from_address`
and optionally `sending_domain`. Create a key with Mail Send permission.
Authenticate the domain and publish SendGrid's DKIM/SPF DNS records; single-sender
verification can be used for initial tests. Confirm the account can send.
`track_opens` controls open tracking; `track_clicks` controls HTML and plain-text
click tracking. Explicit false values override account tracking defaults.
The global endpoint is `https://api.sendgrid.com/v3/mail/send`; only HTTP 202
counts as success and `X-Message-Id` becomes the provider message ID.
One personalization contains To/Cc/Bcc with display names. `custom_args` carries
`qqq_email_id`. Reserved headers are filtered case-insensitively: `x-sg-id`,
`x-sg-eid`, `received`, `dkim-signature`, `Content-Type`, `Content-Transfer-Encoding`,
`To`, `From`, `Subject`, `Reply-To`, `CC`, `BCC`. `List-Unsubscribe` and
`List-Unsubscribe-Post` are retained.
HTTP 401/403 are provider failures, 400/413 permanent, and 429/5xx transient.
Set rate limits for the account's plan/API quota. Payload and recipient limits
apply separately from outbox attempt limits. See
[SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
## Mailgun
Set `kind=MAILGUN`, `secret_env_name=MAILGUN_API_KEY`, `sending_domain` (required,
a DNS name without a scheme/path), and `region=US` or `EU` (required).
The From address domain must match the sending domain.
Add and verify the domain in that region. Publish Mailgun's SPF/DKIM records and
its requested tracking DNS records if enabling tracking. Use an API key allowed
to send for that domain. Sandbox domains restrict recipients; check production
status and plan/rate quotas before choosing `max_per_minute` / `max_per_day`.
Those fields do not enforce MIME-size or recipient limits.
The endpoint is `https://api.mailgun.net/v3/{sending_domain}/messages.mime`, or
`https://api.eu.mailgun.net/v3/{sending_domain}/messages.mime` for EU. Basic auth
uses `api:<resolved key>`. Multipart includes all To/Cc/Bcc mailboxes as envelope
destinations, MIME `message` with Bcc stripped, `o:tracking-opens`,
`o:tracking-clicks` (explicit yes/no from the row), and `v:qqq_email_id`.
The response `id` is stored without surrounding angle brackets.
HTTP 401/403 are provider failures, 400 permanent, and 429/5xx transient.
## Gmail SMTP
| Field | Value |
|---|---|
| `kind` | `GMAIL_SMTP` |
| `username_env_name` | `GMAIL_USERNAME`: value is the full account email address |
| `secret_env_name` | `GMAIL_APP_PASSWORD`: value is the generated app password |
| `security` | `STARTTLS` |
| `from_address` | Account address or a permitted configured alias |
| `max_per_day` | At most about 500 personal / 2000 paid Workspace; lower with headroom |
The preset always uses `smtp.gmail.com:587` with required STARTTLS, ignoring
custom host/port values. Both env names are required; other security values are
refused even if plaintext auth is enabled for other relays. An omitted security
value uses the preset at the settings layer, but provider forms default to NONE,
so select STARTTLS when creating the row.
Enable 2-step verification and generate an app password for the service; do not
use the normal account password. Account/admin security policy may disable app
passwords. A Google password change revokes existing app passwords, requiring a
new environment value. See
[Google app password setup](https://support.google.com/accounts/answer/185833).
Google manages domain authentication for personal gmail.com senders. For
Workspace, verify the domain and configure Google's SPF/DKIM records and DMARC.
Trials, new accounts, SMTP recipient quotas and other account users can reduce
available capacity. The 500/2000 limit is an upper planning bound, not guaranteed
throughput. See [Workspace limits](https://support.google.com/a/answer/166852)
and [personal Gmail limits](https://support.google.com/mail/answer/22839).
`GMAIL_API` is refused with:
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
It would require a Google Cloud project, OAuth consent, client credentials and
refresh-token management. It adds no needed system-mail capability over the app
password preset in this phase and is built only on request (DESIGN 3.6).
## Event webhooks
Phase (c) installs authenticated provider receivers when the host includes Javalin.
Use `https://<app>{base}/{kind}/{providerName}`, where `base` is
`EmailQBitConfig.webhookBasePath` (default `/qqq/hooks/email`), `kind` is `ses`,
`postmark`, `sendgrid` or `mailgun`, and `providerName` is the row's unique name.
The row must be enabled and its kind must match. The configurable base must start
with `/`, have no trailing slash, and stay outside `/qqq/v1`.
| Provider | Setup and events |
|---|---|
| SES | Send configuration-set Delivery, Bounce, Complaint, Reject and DeliveryDelay events to SNS; enable Open/Click only when desired. Set `sns_topic_arn` to that exact topic. Subscribe the HTTPS `/ses/{providerName}` URL with raw delivery **off**, so SNS sends its signed JSON envelope. No webhook secret env is used. Verified subscription confirmations are fetched automatically and retained as IGNORED inbox rows. |
| Postmark | Configure server/message-stream webhooks for Delivery, Bounce, SpamComplaint and SubscriptionChange; optionally Open and Click. Use HTTP Basic username **`postmark`** and the password resolved from `webhook_secret_env_name` (normally `POSTMARK_WEBHOOK_PASSWORD`). Configure those credentials in Postmark's webhook settings/URL; keep the stored provider row limited to the environment name. |
| SendGrid | Enable the Signed Event Webhook for delivered, bounce, dropped, deferred, spamreport and unsubscribe (group_unsubscribe is also accepted); optionally open/click. Store the base64 DER verification public key in `SENDGRID_WEBHOOK_PUBLIC_KEY`, referenced by `webhook_secret_env_name`. Preserve both Twilio signature/timestamp headers through the proxy. Batches are authenticated over timestamp plus the unchanged raw body. |
| Mailgun | Configure regional domain webhooks for delivered, permanent/temporary failed, complained and unsubscribed; optionally opened/clicked. Set `webhook_secret_env_name=MAILGUN_WEBHOOK_SIGNING_KEY` to the account's HTTP webhook signing key, **not** its sending API key. Preserve the JSON `signature` object and `event-data`. |
| SMTP / Gmail SMTP | No native event receiver. One-click unsubscribe remains available for opt-out mail. |
SNS accepts SignatureVersion 1 and 2, pins TopicArn, and fetches certificates only
from HTTPS `sns.<region>.amazonaws.com` or `.amazonaws.com.cn` hosts with `.pem`
paths. Certificates are cached only in memory; redirects are disabled. Confirmation
URLs use the same HTTPS host restriction. Canonicalization follows
[AWS's signature rules](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-verify-message-signature.html).
SNS retries can be old, so freshness is enforced only for SendGrid and Mailgun:
timestamps must be within five minutes in either direction. Keep host clocks synchronized.
Postmark authenticates the request with Basic auth and supplies no signed timestamp.
Requests are capped at 1 MiB, including chunked requests. Responses are JSON:
200 `received` or `duplicate`, 401 invalid verification/Mailgun nonce replay,
404 missing/disabled/mismatched provider, 413 oversized input, and 503 unavailable
verification configuration, SNS fetch or database. No request bodies, recipient
addresses or credential values are logged. Raw payloads are stored in the DB inbox
as sensitive data, readable with `email.view`.
The hidden `email_process_webhooks` schedule runs every 30 seconds. One bounded
transaction applies each inbox row, including events, message state and suppressions.
The common provider lock serializes suppression upserts across replicas/providers;
no network I/O occurs under that lock. Failed attempts back off with `retryMinutes`
and become FAILED after six attempts. Use the inbox's **Reprocess** Review/Result
action (`email.manage`) after resolving the cause. Unmatched messages retry, including
a webhook racing the transport's SENT commit. Unknown/disabled tracking events are
IGNORED. Per-event recipient keys prevent overlapping SendGrid batches from duplicating
history. Mailgun's `(provider_id, nonce)` uniqueness is distinct from event deduplication:
a stored nonce is refused with 401 even if its event is already known.
Hard bounces and complaints upsert ALL suppression; complaints take precedence over
hard-bounce reasons. Unsubscribe events suppress only MARKETING or NOTIFICATION for
the matched message. Delivery advances SENT to DELIVERED; hard bounce and complaint
advance delivery state without reversing a later complaint. Opens/clicks require the
corresponding provider flag and are never retained for PHI-approved messages.
Provider reason text is address-masked and limited to 500 characters; only clicks
retain URLs. More than three complaints in 24 hours raises optional WARNING alert
`email.complaint`, deduplicated per UTC day by core's alert sink.
## One-click unsubscribe
Set `publicBaseUrl` to the public HTTPS app origin and supply
`QQQ_EMAIL_UNSUBSCRIBE_KEY` through the secret resolver. The configured webhook base
also controls newly signed unsubscribe URLs. Keep the key and published path stable:
tokens have no expiry, but cannot resolve after their message row is deleted.
GET `{base}/unsubscribe/{token}` displays a self-contained, escaped light/dark form
and never changes state. POST `List-Unsubscribe=One-Click` returns an empty 200;
the page's form POST returns confirmation HTML. Both atomically upsert category
suppression and record an UNSUBSCRIBED event. Repeated POSTs do not duplicate events.
Invalid tokens return a generic 404. TRANSACTIONAL and SECURITY tokens are neither
issued nor accepted.
The existing v1 token binds the message, category and **complete To/Cc/Bcc envelope
hash**, rather than one mailbox. Following that link opts out every mailbox in the
bound envelope for that category; the page reveals none of those addresses. Send
one recipient per opt-out message when individual preference control is needed.
Preferences UI remains deferred until core #1162 ships in an RC.
-11
View File
@@ -1,11 +0,0 @@
-- Existing PostgreSQL installations: pause enqueue and dispatch before applying.
-- SHA-256 is PostgreSQL's built-in bytea function; no extension is required.
BEGIN;
ALTER TABLE email_message ADD COLUMN rfc_message_id VARCHAR(500);
UPDATE email_message
SET rfc_message_id = '<' || left(encode(sha256(convert_to(
coalesce(idempotency_key, 'qbit-email:' || id::text), 'UTF8')), 'hex'), 32)
|| '@' || coalesce(lower(substring(from_address from
'@([A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)(?:>|$)')), 'qbit-email.local') || '>';
ALTER TABLE email_message ADD CONSTRAINT email_message_rfc_message_id_unique UNIQUE (rfc_message_id);
COMMIT;
-241
View File
@@ -1,241 +0,0 @@
-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host.
CREATE TABLE email_provider (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500),
label VARCHAR(500),
kind VARCHAR(500),
enabled BOOLEAN,
is_default BOOLEAN,
from_address VARCHAR(500),
from_name VARCHAR(500),
reply_to VARCHAR(500),
sending_domain VARCHAR(500),
host VARCHAR(500),
port INTEGER,
security VARCHAR(500),
region VARCHAR(500),
username_env_name VARCHAR(500),
secret_env_name VARCHAR(500),
webhook_secret_env_name VARCHAR(500),
sns_topic_arn VARCHAR(500),
configuration_set VARCHAR(500),
message_stream VARCHAR(500),
max_per_minute INTEGER,
max_per_day INTEGER,
track_opens BOOLEAN,
track_clicks BOOLEAN,
phi_allowed BOOLEAN,
health_status VARCHAR(500),
last_success_date TIMESTAMP,
last_failure_date TIMESTAMP,
last_failure_message VARCHAR(500),
dispatch_lock INTEGER,
default_slot INTEGER,
UNIQUE (name),
UNIQUE (default_slot)
);
CREATE TABLE email_provider_sender_address (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
provider_id INTEGER NOT NULL,
address VARCHAR(500) NOT NULL,
display_name VARCHAR(500),
is_default BOOLEAN,
enabled BOOLEAN,
note TEXT,
UNIQUE (provider_id, address)
);
CREATE TABLE email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
status VARCHAR(500),
to_addresses TEXT,
cc_addresses TEXT,
bcc_addresses TEXT,
from_address VARCHAR(500),
from_address_id INTEGER,
reply_to VARCHAR(500),
subject TEXT,
template_id INTEGER,
template_version_id INTEGER,
brand_id INTEGER,
category VARCHAR(500),
locale VARCHAR(500),
variables_snapshot TEXT,
html_body TEXT,
text_body TEXT,
body_sha256 VARCHAR(500),
phi_approved BOOLEAN,
unsubscribe_url TEXT,
idempotency_key VARCHAR(500),
rfc_message_id VARCHAR(500),
provider_id INTEGER,
provider_message_id VARCHAR(500),
attempt_count INTEGER,
next_attempt_date TIMESTAMP,
lease_expires_date TIMESTAMP,
last_error VARCHAR(500),
finished_date TIMESTAMP,
sent_date TIMESTAMP,
delivered_date TIMESTAMP,
bounced_date TIMESTAMP,
complained_date TIMESTAMP,
dead_letter_date TIMESTAMP,
linked_table_name VARCHAR(500),
linked_record_id VARCHAR(500),
sender_qbit VARCHAR(500),
sender_process VARCHAR(500),
suppressed_addresses TEXT,
send_after TIMESTAMP,
UNIQUE (idempotency_key),
UNIQUE (rfc_message_id)
);
CREATE TABLE email_message_attempt (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
message_id INTEGER NOT NULL,
attempt_number INTEGER NOT NULL,
outcome VARCHAR(500),
provider_id INTEGER,
claimed_by VARCHAR(500),
start_date TIMESTAMP,
end_date TIMESTAMP,
lease_expires_date TIMESTAMP,
duration_millis INTEGER,
error VARCHAR(500),
UNIQUE (message_id, attempt_number)
);
CREATE TABLE email_template (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500),
label VARCHAR(500),
category VARCHAR(500),
kind VARCHAR(500),
brand_id INTEGER,
active_version_id INTEGER,
approved_version_id INTEGER,
version_lock INTEGER,
phi_approved BOOLEAN,
phi_approved_by VARCHAR(500),
phi_approved_date TIMESTAMP,
edit_permission_name VARCHAR(500),
owner_qbit VARCHAR(500),
description TEXT,
UNIQUE (name)
);
CREATE TABLE email_template_version (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
template_id INTEGER,
version_number INTEGER,
status VARCHAR(500),
locale VARCHAR(500),
subject TEXT,
preheader TEXT,
body_source TEXT,
text_source TEXT,
compiled_html TEXT,
variables_schema TEXT,
sample_variables TEXT,
created_by VARCHAR(500),
activated_date TIMESTAMP,
UNIQUE (template_id, version_number, locale)
);
CREATE TABLE email_brand (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500),
label VARCHAR(500),
logo_url VARCHAR(500),
logo_dark_url VARCHAR(500),
logo_alt VARCHAR(500),
primary_color VARCHAR(500),
text_color VARCHAR(500),
background_color VARCHAR(500),
dark_background_color VARCHAR(500),
dark_text_color VARCHAR(500),
dark_primary_color VARCHAR(500),
dark_button_text_color VARCHAR(500),
button_text_color VARCHAR(500),
footer_markdown TEXT,
postal_address TEXT,
social_links TEXT,
site_url VARCHAR(500),
is_default BOOLEAN,
app_name VARCHAR(500),
UNIQUE (name)
);
CREATE TABLE email_suppression (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
address VARCHAR(500),
scope VARCHAR(500),
reason VARCHAR(500),
source_message_id INTEGER,
source_event_id INTEGER,
note TEXT,
created_date TIMESTAMP,
expires_date TIMESTAMP,
UNIQUE (address, scope)
);
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
CREATE TABLE email_webhook_inbox (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
provider_id INTEGER NOT NULL,
kind VARCHAR(500) NOT NULL,
external_event_id VARCHAR(500) NOT NULL,
nonce VARCHAR(500),
status VARCHAR(500) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_date TIMESTAMP,
last_error VARCHAR(500),
payload_json TEXT,
payload_sha256 VARCHAR(500),
received_date TIMESTAMP,
processed_date TIMESTAMP,
UNIQUE (provider_id, external_event_id),
UNIQUE (provider_id, nonce)
);
CREATE TABLE email_event (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
message_id INTEGER NOT NULL,
provider_id INTEGER,
event_type VARCHAR(500) NOT NULL,
bounce_type VARCHAR(500),
recipient VARCHAR(500),
occurred_date TIMESTAMP,
detail VARCHAR(500),
url TEXT,
event_key VARCHAR(500) NOT NULL UNIQUE
);
CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date);
CREATE INDEX email_event_message ON email_event (message_id);
CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date);
CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);
Vendored
-295
View File
@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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
#
# http://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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
-189
View File
@@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+24 -225
View File
@@ -1,142 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2026. 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.
-->
<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>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<h2.version>2.2.224</h2.version>
<commonmark.version>0.27.1</commonmark.version>
<awssdk.version>2.41.34</awssdk.version>
<greenmail.version>2.0.1</greenmail.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Email</name>
<description>QBit for email across a QQQ system: an outbox with retries, SMTP, SES, Postmark, SendGrid, Mailgun and Gmail providers, Markdown and MJML templates with branding, delivery tracking, suppression and one-click unsubscribe</description>
<url>https://github.com/QRun-IO/qbit-email</url>
<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">
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-extension</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<name>QBit Example Extension</name>
<description>Example Extension QBit - rename this for your QBit</description>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-email.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-email.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-email</url>
<tag>HEAD</tag>
</scm>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
</properties>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>${qqq.version}</version>
</dependency>
<!-- Markdown templates (DESIGN 5.2). -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId>
<version>${commonmark.version}</version>
</dependency>
<!-- SES transport (DESIGN 3.4): AWS SDK v2 sesv2. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sesv2</artifactId>
<version>${awssdk.version}</version>
</dependency>
<!-- Webhook and unsubscribe routes (DESIGN 6, 8); the host app provides Javalin. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>${greenmail.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -151,109 +45,14 @@
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.12.6</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<headerLocation>checkstyle/license.txt</headerLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>false</failsOnError>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<excludes>
<exclude>com/kingsrook/qbits/email/demo/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
</plugins>
</build>
<profiles>
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
</project>
@@ -1,94 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.metadata.EmailTables;
/*******************************************************************************
** What a host wires into its ESB, Quick Search and API, without this qbit
** depending on those modules (the same opt-in hooks as qbit-sql-workbench).
**
** <ul>
** <li>{@link #esbTableNames()}: publish these tables' inserts, updates and
** deletes to an ESB topic (the main tables).</li>
** <li>{@link #quickSearchTables()}: index these in Quick Search, with field
** weights.</li>
** <li>{@link #apiTableNames()}: expose these in the host's QQQ API. The API
** gets exactly the admin's rules: the same permissions, customizers and
** read-only tables.</li>
** </ul>
*******************************************************************************/
public final class EmailIntegrations
{
/*******************************************************************************
** A table for Quick Search: field weights, the timestamp for incremental
** indexing, and the record label.
*******************************************************************************/
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailIntegrations()
{
}
/*******************************************************************************
** Tables to publish on the ESB.
*******************************************************************************/
public static List<String> esbTableNames()
{
return (List.of(EmailTables.MESSAGE, EmailTables.TEMPLATE, EmailTables.SUPPRESSION));
}
/*******************************************************************************
** Tables for the host's QQQ API.
*******************************************************************************/
public static List<String> apiTableNames()
{
return (List.of(EmailTables.MESSAGE, EmailTables.TEMPLATE, EmailTables.SUPPRESSION));
}
/*******************************************************************************
** Tables for Quick Search.
*******************************************************************************/
public static List<SearchableTable> quickSearchTables()
{
return (List.of(
new SearchableTable(EmailTables.MESSAGE, Map.of("id", 10, "providerMessageId", 8, "status", 4, "linkedRecordId", 3), "modifyDate", "%s", List.of("id")),
new SearchableTable(EmailTables.TEMPLATE, Map.of("name", 10, "label", 8, "description", 3), "modifyDate", "%s", List.of("name")),
new SearchableTable(EmailTables.SUPPRESSION, Map.of("address", 10, "scope", 4, "reason", 3), "modifyDate", "%s", List.of("address"))));
}
}
@@ -1,609 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Configuration for phase (a) email delivery.
*******************************************************************************/
public class EmailQBitConfig implements QBitConfig
{
private com.kingsrook.qbits.email.template.MjmlCompilerInterface mjmlCompiler;
private java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> classificationOverrides = java.util.Map.of();
private java.util.function.Supplier<com.kingsrook.qbits.email.transport.EmailTransportRegistry> transportRegistryFactory = com.kingsrook.qbits.email.transport.EmailTransportRegistry::new;
private Set<String> allowedImageHosts = Set.of();
private String backendName;
private String schedulerName;
private String publicBaseUrl;
private String webhookBasePath = "/qqq/hooks/email";
private String defaultProviderName;
private String bridgeProviderName = "qbitEmail";
private Function<String, String> secretResolver = System::getenv;
private boolean allowPlaintextSmtpAuth = false;
private Set<String> allowedEnvNames = Set.of();
private Integer dispatchIntervalSeconds = 15;
private Integer batchSize = 100;
private Integer maxAttempts = 6;
private List<Integer> retryMinutes = List.of(1, 5, 15, 60, 240, 720);
private Integer leaseSeconds = 120;
private Integer bodyRetentionDays = 30;
private Integer messageRetentionDays = 0;
private String appName = "email";
private String appLabel = "Email";
private List<QFieldMetaData> securityFields = List.of();
private List<RecordSecurityLock> recordSecurityLocks = List.of();
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
/*******************************************************************************
** Whether the host explicitly permits SMTP credentials without TLS.
*******************************************************************************/
public boolean getAllowPlaintextSmtpAuth()
{
return (allowPlaintextSmtpAuth);
}
/*******************************************************************************
** Opt in to plaintext SMTP authentication outside loopback.
*******************************************************************************/
public EmailQBitConfig withAllowPlaintextSmtpAuth(boolean allowPlaintextSmtpAuth)
{
this.allowPlaintextSmtpAuth = allowPlaintextSmtpAuth;
return (this);
}
/*******************************************************************************
** Validate against the host instance.
*******************************************************************************/
@Override
public void validate(QInstance instance, List<String> errors)
{
if(webhookBasePath == null || !webhookBasePath.matches("/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+")
|| webhookBasePath.equals("/qqq/v1") || webhookBasePath.startsWith("/qqq/v1/"))
{
errors.add("webhookBasePath must be an absolute path outside /qqq/v1, without a trailing slash");
}
if(backendName == null || backendName.isBlank())
{
errors.add("backendName is required");
}
else if(instance.getBackend(backendName) == null)
{
errors.add("backend not found: " + backendName);
}
if(schedulerName != null && instance.getScheduler(schedulerName) == null)
{
errors.add("schedulerName is not a scheduler in the instance");
}
if(maxAttempts == null || maxAttempts < 1 || batchSize == null || batchSize < 1 || dispatchIntervalSeconds == null || dispatchIntervalSeconds < 1)
{
errors.add("Attempts, batch size and dispatch interval must be positive");
}
if(leaseSeconds == null || leaseSeconds < 60)
{
errors.add("leaseSeconds must be at least 60, longer than transport timeouts");
}
if(retryMinutes == null || retryMinutes.isEmpty() || retryMinutes.stream().anyMatch(n -> n == null || n < 1))
{
errors.add("retryMinutes must contain positive delays");
}
if(bodyRetentionDays == null || bodyRetentionDays < 0 || messageRetentionDays == null || messageRetentionDays < 0)
{
errors.add("Retention days must not be negative");
}
if(secretResolver == null || allowedEnvNames == null || allowedEnvNames.stream().anyMatch(n -> n == null || !ENV_NAME.matcher(n).matches()))
{
errors.add("A secret resolver and valid allowed environment names are required");
}
if(bridgeProviderName == null || bridgeProviderName.isBlank() || appName == null || appName.isBlank())
{
errors.add("Bridge provider and app names are required");
}
}
/*******************************************************************************
** Retry minutes after a one-based attempt, repeating the final delay.
*******************************************************************************/
public int retryMinutesAfterAttempt(int number)
{
return (retryMinutes.get(Math.max(0, Math.min(number - 1, retryMinutes.size() - 1))));
}
/*******************************************************************************
** Allowed names include the documented defaults for this provider kind.
*******************************************************************************/
public Set<String> effectiveAllowedEnvNames(String kind)
{
Set<String> names = new HashSet<>(allowedEnvNames);
names.addAll(switch(kind == null ? "" : kind)
{
case "SMTP", "GMAIL_SMTP" -> Set.of("EMAIL_SMTP_USERNAME", "EMAIL_SMTP_PASSWORD", "EMAIL_GMAIL_USERNAME", "EMAIL_GMAIL_APP_PASSWORD", "GMAIL_USERNAME", "GMAIL_APP_PASSWORD");
case "SES" -> Set.of("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN");
case "POSTMARK" -> Set.of("EMAIL_POSTMARK_SERVER_TOKEN", "EMAIL_POSTMARK_WEBHOOK_PASSWORD", "POSTMARK_SERVER_TOKEN", "POSTMARK_WEBHOOK_PASSWORD");
case "SENDGRID" -> Set.of("EMAIL_SENDGRID_API_KEY", "EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY", "SENDGRID_API_KEY", "SENDGRID_WEBHOOK_PUBLIC_KEY");
case "MAILGUN" -> Set.of("EMAIL_MAILGUN_API_KEY", "EMAIL_MAILGUN_SIGNING_KEY", "MAILGUN_API_KEY", "MAILGUN_WEBHOOK_SIGNING_KEY");
case "GMAIL_API" -> Set.of("EMAIL_GMAIL_CLIENT_ID", "EMAIL_GMAIL_CLIENT_SECRET", "EMAIL_GMAIL_REFRESH_TOKEN");
default -> Set.of();
});
return (names);
}
/*******************************************************************************
** Getter for backendName.
*******************************************************************************/
public String getBackendName()
{
return (backendName);
}
/*******************************************************************************
** Fluent setter for backendName.
*******************************************************************************/
public EmailQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for schedulerName.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Fluent setter for schedulerName.
*******************************************************************************/
public EmailQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Getter for publicBaseUrl.
*******************************************************************************/
public String getPublicBaseUrl()
{
return (publicBaseUrl);
}
/*******************************************************************************
** Public provider and unsubscribe route base.
*******************************************************************************/
public String getWebhookBasePath()
{
return (webhookBasePath);
}
/*******************************************************************************
** Configure the route base and queue-time unsubscribe URLs together.
*******************************************************************************/
public EmailQBitConfig withWebhookBasePath(String webhookBasePath)
{
this.webhookBasePath = webhookBasePath;
return (this);
}
/*******************************************************************************
** Fluent setter for publicBaseUrl.
*******************************************************************************/
public EmailQBitConfig withPublicBaseUrl(String publicBaseUrl)
{
this.publicBaseUrl = publicBaseUrl;
return (this);
}
/*******************************************************************************
** Getter for defaultProviderName.
*******************************************************************************/
public String getDefaultProviderName()
{
return (defaultProviderName);
}
/*******************************************************************************
** Fluent setter for defaultProviderName.
*******************************************************************************/
public EmailQBitConfig withDefaultProviderName(String defaultProviderName)
{
this.defaultProviderName = defaultProviderName;
return (this);
}
/*******************************************************************************
** Getter for bridgeProviderName.
*******************************************************************************/
public String getBridgeProviderName()
{
return (bridgeProviderName);
}
/*******************************************************************************
** Fluent setter for bridgeProviderName.
*******************************************************************************/
public EmailQBitConfig withBridgeProviderName(String bridgeProviderName)
{
this.bridgeProviderName = bridgeProviderName;
return (this);
}
/*******************************************************************************
** Getter for secretResolver.
*******************************************************************************/
public Function<String, String> getSecretResolver()
{
return (secretResolver);
}
/*******************************************************************************
** Fluent setter for secretResolver.
*******************************************************************************/
public EmailQBitConfig withSecretResolver(Function<String, String> secretResolver)
{
this.secretResolver = secretResolver;
return (this);
}
/*******************************************************************************
** Getter for allowedEnvNames.
*******************************************************************************/
public Set<String> getAllowedEnvNames()
{
return (allowedEnvNames);
}
/*******************************************************************************
** Fluent setter for allowedEnvNames.
*******************************************************************************/
public EmailQBitConfig withAllowedEnvNames(Set<String> allowedEnvNames)
{
this.allowedEnvNames = allowedEnvNames;
return (this);
}
/*******************************************************************************
** Getter for dispatchIntervalSeconds.
*******************************************************************************/
public Integer getDispatchIntervalSeconds()
{
return (dispatchIntervalSeconds);
}
/*******************************************************************************
** Fluent setter for dispatchIntervalSeconds.
*******************************************************************************/
public EmailQBitConfig withDispatchIntervalSeconds(Integer dispatchIntervalSeconds)
{
this.dispatchIntervalSeconds = dispatchIntervalSeconds;
return (this);
}
/*******************************************************************************
** Getter for batchSize.
*******************************************************************************/
public Integer getBatchSize()
{
return (batchSize);
}
/*******************************************************************************
** Fluent setter for batchSize.
*******************************************************************************/
public EmailQBitConfig withBatchSize(Integer batchSize)
{
this.batchSize = batchSize;
return (this);
}
/*******************************************************************************
** Getter for maxAttempts.
*******************************************************************************/
public Integer getMaxAttempts()
{
return (maxAttempts);
}
/*******************************************************************************
** Fluent setter for maxAttempts.
*******************************************************************************/
public EmailQBitConfig withMaxAttempts(Integer maxAttempts)
{
this.maxAttempts = maxAttempts;
return (this);
}
/*******************************************************************************
** Getter for retryMinutes.
*******************************************************************************/
public List<Integer> getRetryMinutes()
{
return (retryMinutes);
}
/*******************************************************************************
** Fluent setter for retryMinutes.
*******************************************************************************/
public EmailQBitConfig withRetryMinutes(List<Integer> retryMinutes)
{
this.retryMinutes = retryMinutes;
return (this);
}
/*******************************************************************************
** Getter for leaseSeconds.
*******************************************************************************/
public Integer getLeaseSeconds()
{
return (leaseSeconds);
}
/*******************************************************************************
** Fluent setter for leaseSeconds.
*******************************************************************************/
public EmailQBitConfig withLeaseSeconds(Integer leaseSeconds)
{
this.leaseSeconds = leaseSeconds;
return (this);
}
/*******************************************************************************
** Getter for bodyRetentionDays.
*******************************************************************************/
public Integer getBodyRetentionDays()
{
return (bodyRetentionDays);
}
/*******************************************************************************
** Fluent setter for bodyRetentionDays.
*******************************************************************************/
public EmailQBitConfig withBodyRetentionDays(Integer bodyRetentionDays)
{
this.bodyRetentionDays = bodyRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for messageRetentionDays.
*******************************************************************************/
public Integer getMessageRetentionDays()
{
return (messageRetentionDays);
}
/*******************************************************************************
** Fluent setter for messageRetentionDays.
*******************************************************************************/
public EmailQBitConfig withMessageRetentionDays(Integer messageRetentionDays)
{
this.messageRetentionDays = messageRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for appName.
*******************************************************************************/
public String getAppName()
{
return (appName);
}
/*******************************************************************************
** Fluent setter for appName.
*******************************************************************************/
public EmailQBitConfig withAppName(String appName)
{
this.appName = appName;
return (this);
}
/*******************************************************************************
** Getter for appLabel.
*******************************************************************************/
public String getAppLabel()
{
return (appLabel);
}
/*******************************************************************************
** Fluent setter for appLabel.
*******************************************************************************/
public EmailQBitConfig withAppLabel(String appLabel)
{
this.appLabel = appLabel;
return (this);
}
/*******************************************************************************
** Getter for securityFields.
*******************************************************************************/
public List<QFieldMetaData> getSecurityFields()
{
return (securityFields);
}
/*******************************************************************************
** Fluent setter for securityFields.
*******************************************************************************/
public EmailQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
{
this.securityFields = securityFields;
return (this);
}
/*******************************************************************************
** Getter for recordSecurityLocks.
*******************************************************************************/
public List<RecordSecurityLock> getRecordSecurityLocks()
{
return (recordSecurityLocks);
}
/*******************************************************************************
** Fluent setter for recordSecurityLocks.
*******************************************************************************/
public EmailQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
{
this.recordSecurityLocks = recordSecurityLocks;
return (this);
}
/*******************************************************************************
** Getter for tableMetaDataCustomizer.
*******************************************************************************/
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
{
return (tableMetaDataCustomizer);
}
/*******************************************************************************
** Fluent setter for tableMetaDataCustomizer.
*******************************************************************************/
public EmailQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
{
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
return (this);
}
/*******************************************************************************
** Activation-time compiler, optional unless MJML templates are used.
*******************************************************************************/
public com.kingsrook.qbits.email.template.MjmlCompilerInterface getMjmlCompiler()
{
return (mjmlCompiler);
}
/*******************************************************************************
** Configure a compiler without introducing a runtime dependency.
*******************************************************************************/
public EmailQBitConfig withMjmlCompiler(com.kingsrook.qbits.email.template.MjmlCompilerInterface compiler)
{
mjmlCompiler = compiler;
return (this);
}
/*******************************************************************************
** Field classifications used until core supplies classification metadata.
*******************************************************************************/
public java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> getClassificationOverrides()
{
return (classificationOverrides);
}
/*******************************************************************************
** Configure explicit table.field classifications.
*******************************************************************************/
public EmailQBitConfig withClassificationOverrides(java.util.Map<String, com.kingsrook.qbits.email.phi.EmailClassifications.Level> overrides)
{
classificationOverrides = java.util.Map.copyOf(overrides);
return (this);
}
/*******************************************************************************
** Additional HTTPS image hosts permitted for approved email content.
*******************************************************************************/
public Set<String> getAllowedImageHosts()
{
return (allowedImageHosts);
}
/*******************************************************************************
** Configure exact host names, without wildcards or URL paths.
*******************************************************************************/
public EmailQBitConfig withAllowedImageHosts(Set<String> hosts)
{
allowedImageHosts = hosts.stream().map(host -> host.toLowerCase(java.util.Locale.ROOT)).collect(java.util.stream.Collectors.toUnmodifiableSet());
return (this);
}
/*******************************************************************************
** Fresh transports for every dispatcher, including process actions.
*******************************************************************************/
public com.kingsrook.qbits.email.transport.EmailTransportRegistry newTransportRegistry()
{
return (transportRegistryFactory.get());
}
/*******************************************************************************
** Host-scoped transport injection for embedded applications and local demos.
*******************************************************************************/
public EmailQBitConfig withTransportRegistryFactory(java.util.function.Supplier<com.kingsrook.qbits.email.transport.EmailTransportRegistry> factory)
{
transportRegistryFactory = java.util.Objects.requireNonNull(factory);
return (this);
}
}
@@ -1,219 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.app.EmailApp;
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
import com.kingsrook.qbits.email.delivery.EmailAlerts;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Entry point of the email outbox qbit.
*******************************************************************************/
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";
private EmailQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
public EmailQBitProducer withQBitConfig(EmailQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Getter for the config.
*******************************************************************************/
@Override
public EmailQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** Fluent setter for the namespace.
*******************************************************************************/
public EmailQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** Getter for the namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
/*******************************************************************************
** Identity.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
}
/*******************************************************************************
** Package-scanned producers have no config: skip them.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Validate and register the tables, bridge, processes, alerts and app.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw (new QException("Email qbit configuration is invalid: " + String.join("; ", errors)));
}
qInstance.addQBit(getQBitMetaData());
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
EmailTables.possibleValueSources().forEach(output::add);
Map<String, QTableMetaData> tables = new LinkedHashMap<>();
for(QTableMetaData table : EmailTables.tables(qBitConfig))
{
Class<?> customizer = EmailTables.PROVIDER.equals(table.getName()) ? EmailTableCustomizers.ProviderCustomizer.class
: EmailTables.SENDER.equals(table.getName()) ? EmailTableCustomizers.SenderAddressCustomizer.class
: EmailTables.SUPPRESSION.equals(table.getName()) ? EmailTableCustomizers.SuppressionCustomizer.class
: List.of(EmailTables.TEMPLATE, EmailTables.VERSION, EmailTables.BRAND).contains(table.getName()) ? com.kingsrook.qbits.email.template.EmailTemplateCustomizers.class : null;
if(customizer != null)
{
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(customizer))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(customizer))
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(customizer));
}
if(EmailTables.MESSAGE.equals(table.getName()))
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.MessageLinksCustomizer.class));
}
if(EmailTables.VERSION.equals(table.getName()))
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.email.template.EmailVersionLabels.class));
}
if(EmailTables.PROVIDER.equals(table.getName()))
{
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.ProviderCustomizer.class));
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
{
table.withVirtualField(new QVirtualFieldMetaData(field + "Status", QFieldType.STRING)
.withLabel(field.equals("usernameEnvName") ? "Username set" : field.equals("secretEnvName") ? "Secret set" : "Webhook secret set")
.withIsEditable(false).withIsQuerySelectable(true));
}
table.getSections().get(1).withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, section ->
{
List<String> fields = new ArrayList<>(section.getFieldNames());
fields.addAll(List.of("usernameEnvNameStatus", "secretEnvNameStatus", "webhookSecretEnvNameStatus"));
section.setFieldNames(fields);
});
}
if(qBitConfig.getTableMetaDataCustomizer() != null)
{
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
}
table.setSourceQBitName(getQBitMetaData().getName());
tables.put(table.getName(), table);
output.add(table);
}
EmailTables.joins().forEach(output::add);
EmailTables.childWidgets().forEach(output::add);
EmailApp.widgets().forEach(output::add);
EmailProcesses.processes(qBitConfig).forEach(process ->
{
process.setSourceQBitName(getQBitMetaData().getName());
output.add(process);
});
EmailAlerts.types().forEach(output::add);
output.add(EmailApp.app(qBitConfig, tables));
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
registerWebhooks(qInstance);
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** Keep optional provided middleware linkage outside non-web applications.
*******************************************************************************/
private static void registerWebhooks(QInstance instance) throws QException
{
try
{
Class.forName("io.javalin.Javalin");
Class.forName("com.kingsrook.qqq.middleware.javalin.QJavalinMetaData");
}
catch(ClassNotFoundException e)
{
return;
}
com.kingsrook.qbits.email.webhook.EmailWebhookRouteProvider.register(instance);
}
}
@@ -1,126 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email;
import java.lang.management.ManagementFactory;
import java.time.Clock;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
/*******************************************************************************
** Runtime lookups for the qbit.
**
** <p>The config is read from the current QInstance on every call (never held
** in a static), so reconfiguring, or two instances in one JVM, never see
** stale settings. The only JVM-wide state is the runner identity
** and the clock, which tests replace.</p>
*******************************************************************************/
public final class EmailRuntime
{
private static final String NODE_ID = ManagementFactory.getRuntimeMXBean().getName() + "/" + UUID.randomUUID().toString().substring(0, 8);
private static volatile Clock clock = Clock.systemUTC();
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailRuntime()
{
}
/*******************************************************************************
** The config in the current instance, or null when the qbit is not installed.
*******************************************************************************/
public static EmailQBitConfig config()
{
return (config(QContext.getQInstance()));
}
/*******************************************************************************
** The config in an instance, or null when the qbit is not installed.
*******************************************************************************/
public static EmailQBitConfig config(QInstance qInstance)
{
if(qInstance == null || qInstance.getQBits() == null)
{
return (null);
}
for(QBitMetaData qBit : qInstance.getQBits().values())
{
if(EmailQBitProducer.ARTIFACT_ID.equals(qBit.getArtifactId()) && qBit.getConfig() instanceof EmailQBitConfig config)
{
return (config);
}
}
return (null);
}
/*******************************************************************************
** True when the qbit is installed in the current instance.
*******************************************************************************/
public static boolean isInstalled()
{
return (config() != null);
}
/*******************************************************************************
** The clock (tests replace it).
*******************************************************************************/
public static Clock clock()
{
return (clock);
}
/*******************************************************************************
** Replace the clock (tests).
*******************************************************************************/
public static void setClock(Clock newClock)
{
clock = newClock == null ? Clock.systemUTC() : newClock;
}
/*******************************************************************************
** Runner identity stored on claimed attempts.
*******************************************************************************/
public static String nodeId()
{
return (NODE_ID);
}
}
@@ -1,420 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailEffects;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.support.EmailApiMapper;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Stable contract for other qbits. Changes within a 0.x minor release line are
** additive only; implementation packages are not API and may change.
** Database operations use the current QContext. Reads run as the email system
** and return only the documented projection; callers must authorize any
** exposure of this metadata to their own users. Sends retain all existing
** permission, validation, PHI, suppression and recipient checks.
*******************************************************************************/
public final class EmailApi
{
private static final Set<String> MESSAGE_FIELDS = Set.of("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId",
"templateId", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailApi()
{
}
/*******************************************************************************
** True only when the email qbit was produced in the supplied instance.
** Does not require a current context; null is not installed.
*******************************************************************************/
public static boolean isInstalled(QInstance instance)
{
return (EmailRuntime.config(instance) != null);
}
/*******************************************************************************
** Queue a message. QUEUED is not a delivery acknowledgement. Null requests
** are refused by the same service used by core messaging.
*******************************************************************************/
public static EmailApiResult send(EmailApiRequest request) throws QException
{
return (EmailApiMapper.result(EmailService.send(EmailApiMapper.request(request))));
}
/*******************************************************************************
** Commit one durable handoff with the effect key as the idempotency key.
** Refuse null requests, blank or over-500-character keys, conflicting keys
** and caller transactions before changing the request. Suppression is REFUSED,
** including replays; a retained audit row is returned. Success acknowledges
** committed storage, not delivery. Database failures propagate as QException.
*******************************************************************************/
public static EmailApiEffectResult sendOnce(EmailApiRequest request, String effectKey) throws QException
{
if(request == null || effectKey == null || effectKey.isBlank() || effectKey.length() > 500
|| request.getTransaction() != null
|| request.getIdempotencyKey() != null && !request.getIdempotencyKey().equals(effectKey))
{
return (new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
}
request.withIdempotencyKey(effectKey);
var result = EmailEffects.send(EmailApiMapper.request(request), effectKey);
EmailApiMessage message = result.message() == null ? null : getMessage(result.message().id())
.orElseThrow(() -> new QException("Queued email reference is unavailable"));
return (new EmailApiEffectResult(EmailApiEffectOutcome.valueOf(result.outcome().name()), message));
}
/*******************************************************************************
** Create a validated template and active version once. Returns false when
** it already exists. Invalid definitions throw IllegalArgumentException.
** Does not grant PHI approval or overwrite administrator edits.
*******************************************************************************/
public static boolean ensureTemplate(EmailApiTemplate template) throws QException
{
return (EmailTemplates.ensure(EmailApiMapper.template(template)));
}
/*******************************************************************************
** System lookup of delivery metadata. Null and unknown ids return empty.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessage(Integer messageId) throws QException
{
if(messageId == null)
{
return (Optional.empty());
}
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", messageId).withLimit(1), MESSAGE_FIELDS);
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
}
/*******************************************************************************
** System lookup by exact effect or application key. Null, blank and unknown
** keys return empty. Returns current metadata without message content.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessageByIdempotencyKey(String key) throws QException
{
return (findMessage("idempotencyKey", key));
}
/*******************************************************************************
** System lookup by the exact bracketed RFC Message-ID stored at enqueue time.
** Null, blank and unknown ids return empty; provider response ids are distinct.
*******************************************************************************/
public static Optional<EmailApiMessage> getMessageByRfcMessageId(String rfcMessageId) throws QException
{
return (findMessage("rfcMessageId", rfcMessageId));
}
/*******************************************************************************
** Share exact-key lookup semantics and the metadata-only projection.
*******************************************************************************/
private static Optional<EmailApiMessage> findMessage(String field, String value) throws QException
{
if(value == null || value.isBlank())
{
return (Optional.empty());
}
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal(field, value).withLimit(1), MESSAGE_FIELDS);
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
}
/*******************************************************************************
** System lookup by literal prefix, ordered by ascending message id. Prefix
** must be nonblank and limit positive; invalid arguments are rejected.
** Returns at most limit rows, without message content or recipient data.
*******************************************************************************/
public static List<EmailApiMessage> findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException
{
if(prefix == null || prefix.isBlank() || limit < 1)
{
throw (new IllegalArgumentException("A nonblank idempotency key prefix and a positive limit are required"));
}
///////////////////////////////////////////////////////////////////////////
// QQQ SQL STARTS_WITH uses LIKE without escaping. Narrow by the literal //
// portion before any wildcard, then check the full prefix in Java. Page //
// by id so wildcard false positives cannot consume the requested limit. //
///////////////////////////////////////////////////////////////////////////
String candidatePrefix = prefix.split("[%_\\\\]", 2)[0];
List<EmailApiMessage> messages = new ArrayList<>();
int lastId = 0;
while(messages.size() < limit)
{
int batchSize = Math.min(500, limit - messages.size());
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, candidatePrefix))
.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId))
.withOrderBy(new QFilterOrderBy("id")).withLimit(batchSize);
List<QRecord> rows = read(EmailTables.MESSAGE, filter, MESSAGE_FIELDS);
for(QRecord row : rows)
{
lastId = row.getValueInteger("id");
if(row.getValueString("idempotencyKey") != null && row.getValueString("idempotencyKey").startsWith(prefix))
{
messages.add(message(row));
}
}
if(rows.size() < batchSize)
{
break;
}
}
return (List.copyOf(messages));
}
/*******************************************************************************
** Enabled sender rows of an enabled named provider, ordered by id. Null
** selects the effective default provider. No match returns an empty list.
** The provider's legacy from-address fallback is not a sender row.
*******************************************************************************/
public static List<EmailApiSenderAddress> listSenderAddresses(String providerName) throws QException
{
QRecord provider = provider(QContext.getQInstance(), providerName);
if(provider == null)
{
return (List.of());
}
QQueryFilter filter = EmailStore.equal("providerId", provider.getValueInteger("id"))
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id"));
return (read(EmailTables.SENDER, filter, Set.of("id", "address", "displayName", "isDefault"))
.stream().map(row -> new EmailApiSenderAddress(row.getValueInteger("id"), row.getValueString("address"),
row.getValueString("displayName"), Boolean.TRUE.equals(row.getValueBoolean("isDefault")))).toList());
}
/*******************************************************************************
** Effective enabled default: the configured name takes precedence over the
** default row. Empty when absent, disabled or ambiguous. An installed
** instance must be the current QContext instance, otherwise this throws.
*******************************************************************************/
public static Optional<String> defaultProviderName(QInstance instance) throws QException
{
QRecord provider = provider(instance, null);
return (provider == null ? Optional.empty() : Optional.of(provider.getValueString("name")));
}
/*******************************************************************************
** Preflight the default sending configuration for a category. Null category
** means TRANSACTIONAL. This does not validate a particular template,
** recipient or permission, or contact a transport. PHI callers use the
** overload with phiApproved set to true.
*******************************************************************************/
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category) throws QException
{
return (readiness(instance, category, false));
}
/*******************************************************************************
** Also check prerequisites for a PHI-approved template. PHI is a template
** property, not an email category. Checks body key availability and provider
** PHI/tracking policy; does not approve a template. Resolver failures are
** reported as missing keys without exposing exception details or values.
*******************************************************************************/
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category, boolean phiApproved) throws QException
{
List<String> reasons = new ArrayList<>();
var config = EmailRuntime.config(instance);
if(config == null)
{
return (new EmailApiReadiness(false, List.of("Email qbit is not installed")));
}
QRecord provider = provider(instance, null);
if(provider == null)
{
reasons.add("An enabled named or default provider is required");
}
else
{
if(!EmailTableCustomizers.validateProvider(provider, config).isEmpty())
{
reasons.add("The default provider configuration is invalid");
}
if(phiApproved)
{
try
{
EmailTemplateRenderer.checkProvider(provider);
}
catch(IllegalArgumentException e)
{
reasons.add("PHI email needs a PHI-enabled provider with open and click tracking disabled");
}
}
}
if(category == EmailApiCategory.MARKETING)
{
try
{
if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank())
{
throw (new IllegalArgumentException());
}
VariableSchema.url(config.getPublicBaseUrl());
}
catch(IllegalArgumentException e)
{
reasons.add("MARKETING email needs a valid publicBaseUrl");
}
if(!hasSecret(config.getSecretResolver(), "QQQ_EMAIL_UNSUBSCRIBE_KEY"))
{
reasons.add("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY");
}
List<QRecord> brands = read(EmailTables.BRAND, EmailStore.equal("isDefault", true).withLimit(2), Set.of("id", "postalAddress"));
if(brands.size() != 1 || brands.getFirst().getValueString("postalAddress") == null || brands.getFirst().getValueString("postalAddress").isBlank())
{
reasons.add("MARKETING email needs a default brand with a postal address");
}
}
if(phiApproved && !hasSecret(config.getSecretResolver(), "QQQ_EMAIL_BODY_KEY"))
{
reasons.add("PHI email needs QQQ_EMAIL_BODY_KEY");
}
return (new EmailApiReadiness(reasons.isEmpty(), reasons));
}
/*******************************************************************************
** Presence only; neither retain values nor expose resolver failures.
*******************************************************************************/
private static boolean hasSecret(Function<String, String> resolver, String name)
{
try
{
String value = resolver.apply(name);
return (value != null && !value.isBlank());
}
catch(RuntimeException e)
{
return (false);
}
}
/*******************************************************************************
** Match the enqueue service's configured-name/default-row precedence.
** Refuse a mismatched context instead of reading another instance's data.
*******************************************************************************/
private static QRecord provider(QInstance instance, String name) throws QException
{
var config = EmailRuntime.config(instance);
if(config == null)
{
return (null);
}
if(instance != QContext.getQInstance())
{
throw (new IllegalArgumentException("The supplied instance must be the current QContext instance"));
}
String effectiveName = name == null ? config.getDefaultProviderName() : name;
QQueryFilter filter = EmailStore.equal("enabled", true)
.withCriteria(new QFilterCriteria(effectiveName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, effectiveName == null ? true : effectiveName))
.withLimit(2);
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, filter);
return (providers.size() == 1 ? providers.getFirst() : null);
}
/*******************************************************************************
** Read an explicit projection; message bodies are never fetched here.
*******************************************************************************/
private static List<QRecord> read(String table, QQueryFilter filter, Set<String> fields) throws QException
{
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
}
/*******************************************************************************
** Resolve the template name and copy only the approved metadata fields.
*******************************************************************************/
private static EmailApiMessage message(QRecord row) throws QException
{
String templateName = null;
if(row.getValueInteger("templateId") != null)
{
List<QRecord> templates = read(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValueInteger("templateId")).withLimit(1), Set.of("id", "name"));
if(!templates.isEmpty())
{
templateName = templates.getFirst().getValueString("name");
}
}
String category = row.getValueString("category");
return (new EmailApiMessage(row.getValueInteger("id"), row.getValueString("status"), category == null ? null : EmailApiCategory.valueOf(category),
row.getValueString("idempotencyKey"), row.getValueString("rfcMessageId"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"),
row.getValueString("linkedRecordId"), row.getValueInstant("createDate"), row.getValueInstant("sentDate"), row.getValueInstant("deliveredDate")));
}
}
@@ -1,33 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** Stable sending categories; PHI approval belongs to the template, not the category.
*******************************************************************************/
public enum EmailApiCategory
{
TRANSACTIONAL,
NOTIFICATION,
MARKETING,
SECURITY
}
@@ -1,31 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** Durable handoff outcomes. Operational failures propagate to the caller.
*******************************************************************************/
public enum EmailApiEffectOutcome
{
QUEUED_OR_DUPLICATE,
REFUSED
}
@@ -1,30 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** Durable handoff result. The message is null when no outbox row was retained.
** Suppressed audit rows carry metadata even though the outcome is REFUSED.
*******************************************************************************/
public record EmailApiEffectResult(EmailApiEffectOutcome outcome, EmailApiMessage message)
{
}
@@ -1,34 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.time.Instant;
/*******************************************************************************
** Immutable delivery metadata. No addresses, subject, bodies or variable snapshots.
** Status is the persisted delivery state, distinct from EmailApiStatus enqueue outcomes.
*******************************************************************************/
public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String rfcMessageId, String providerMessageId,
String templateName, String linkedTableName, String linkedRecordId, Instant createDate, Instant sentDate, Instant deliveredDate)
{
}
@@ -1,39 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.util.List;
/*******************************************************************************
** Configuration preflight, not a delivery guarantee. Reasons never contain secret values.
*******************************************************************************/
public record EmailApiReadiness(boolean ready, List<String> reasons)
{
/*******************************************************************************
** Copy configuration reasons.
*******************************************************************************/
public EmailApiReadiness
{
reasons = List.copyOf(reasons);
}
}
@@ -1,670 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
/*******************************************************************************
** Stable enqueue request. One send always goes through the outbox.
** Null category means TRANSACTIONAL for ad-hoc mail; templates select their category.
** Recipient lists and variable maps are mutable request-building state.
*******************************************************************************/
public final class EmailApiRequest
{
private List<String> to = new ArrayList<>();
private List<String> cc = new ArrayList<>();
private List<String> bcc = new ArrayList<>();
private String from;
private Integer fromAddressId;
private String replyTo;
private String subject;
private String markdownBody;
private String htmlBody;
private String textBody;
private String providerName;
private String templateName;
private String brandName;
private String locale = "en";
private EmailApiCategory category;
private String idempotencyKey;
private QBackendTransaction transaction;
private String transactionBackendName;
private Instant sendAfter;
private String linkedTableName;
private String linkedRecordId;
private String senderQbit;
private String senderProcess;
private Map<String, Serializable> variables = new LinkedHashMap<>();
private Map<String, RecordVariable> recordVariables = new LinkedHashMap<>();
/*******************************************************************************
** Getter for to.
*******************************************************************************/
public List<String> getTo()
{
return (to);
}
/*******************************************************************************
** Fluent setter for to.
*******************************************************************************/
public EmailApiRequest withTo(List<String> to)
{
this.to = to;
return (this);
}
/*******************************************************************************
** Getter for cc.
*******************************************************************************/
public List<String> getCc()
{
return (cc);
}
/*******************************************************************************
** Fluent setter for cc.
*******************************************************************************/
public EmailApiRequest withCc(List<String> cc)
{
this.cc = cc;
return (this);
}
/*******************************************************************************
** Getter for bcc.
*******************************************************************************/
public List<String> getBcc()
{
return (bcc);
}
/*******************************************************************************
** Fluent setter for bcc.
*******************************************************************************/
public EmailApiRequest withBcc(List<String> bcc)
{
this.bcc = bcc;
return (this);
}
/*******************************************************************************
** Getter for from.
*******************************************************************************/
public String getFrom()
{
return (from);
}
/*******************************************************************************
** Fluent setter for from.
*******************************************************************************/
public EmailApiRequest withFrom(String from)
{
this.from = from;
return (this);
}
/*******************************************************************************
** Getter for fromAddressId.
*******************************************************************************/
public Integer getFromAddressId()
{
return (fromAddressId);
}
/*******************************************************************************
** Fluent setter for fromAddressId.
*******************************************************************************/
public EmailApiRequest withFromAddressId(Integer fromAddressId)
{
this.fromAddressId = fromAddressId;
return (this);
}
/*******************************************************************************
** Getter for replyTo.
*******************************************************************************/
public String getReplyTo()
{
return (replyTo);
}
/*******************************************************************************
** Fluent setter for replyTo.
*******************************************************************************/
public EmailApiRequest withReplyTo(String replyTo)
{
this.replyTo = replyTo;
return (this);
}
/*******************************************************************************
** Getter for subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Fluent setter for subject.
*******************************************************************************/
public EmailApiRequest withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Getter for markdownBody.
*******************************************************************************/
public String getMarkdownBody()
{
return (markdownBody);
}
/*******************************************************************************
** Fluent setter for markdownBody.
*******************************************************************************/
public EmailApiRequest withMarkdownBody(String markdownBody)
{
this.markdownBody = markdownBody;
return (this);
}
/*******************************************************************************
** Getter for htmlBody.
*******************************************************************************/
public String getHtmlBody()
{
return (htmlBody);
}
/*******************************************************************************
** Fluent setter for htmlBody.
*******************************************************************************/
public EmailApiRequest withHtmlBody(String htmlBody)
{
this.htmlBody = htmlBody;
return (this);
}
/*******************************************************************************
** Getter for textBody.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** Fluent setter for textBody.
*******************************************************************************/
public EmailApiRequest withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** Getter for providerName.
*******************************************************************************/
public String getProviderName()
{
return (providerName);
}
/*******************************************************************************
** Fluent setter for providerName.
*******************************************************************************/
public EmailApiRequest withProviderName(String providerName)
{
this.providerName = providerName;
return (this);
}
/*******************************************************************************
** Getter for templateName.
*******************************************************************************/
public String getTemplateName()
{
return (templateName);
}
/*******************************************************************************
** Fluent setter for templateName.
*******************************************************************************/
public EmailApiRequest withTemplateName(String templateName)
{
this.templateName = templateName;
return (this);
}
/*******************************************************************************
** Getter for brandName.
*******************************************************************************/
public String getBrandName()
{
return (brandName);
}
/*******************************************************************************
** Fluent setter for brandName.
*******************************************************************************/
public EmailApiRequest withBrandName(String brandName)
{
this.brandName = brandName;
return (this);
}
/*******************************************************************************
** Getter for locale.
*******************************************************************************/
public String getLocale()
{
return (locale);
}
/*******************************************************************************
** Fluent setter for locale.
*******************************************************************************/
public EmailApiRequest withLocale(String locale)
{
this.locale = locale;
return (this);
}
/*******************************************************************************
** Getter for category.
*******************************************************************************/
public EmailApiCategory getCategory()
{
return (category);
}
/*******************************************************************************
** Fluent setter for category.
*******************************************************************************/
public EmailApiRequest withCategory(EmailApiCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Fluent setter for idempotencyKey.
*******************************************************************************/
public EmailApiRequest withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Getter for the optional caller transaction. sendOnce refuses this option.
*******************************************************************************/
public QBackendTransaction getTransaction()
{
return (transaction);
}
/*******************************************************************************
** Getter for the declared backend of the caller transaction.
*******************************************************************************/
public String getTransactionBackendName()
{
return (transactionBackendName);
}
/*******************************************************************************
** Join a caller transaction for ordinary send; another backend defers enqueue
** until commit. Declare its backend because QBackendTransaction does not expose
** it. sendOnce refuses any caller transaction to guarantee committed handoff.
*******************************************************************************/
public EmailApiRequest withTransaction(QBackendTransaction transaction, String backendName)
{
this.transaction = transaction;
this.transactionBackendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for sendAfter.
*******************************************************************************/
public Instant getSendAfter()
{
return (sendAfter);
}
/*******************************************************************************
** Fluent setter for sendAfter.
*******************************************************************************/
public EmailApiRequest withSendAfter(Instant sendAfter)
{
this.sendAfter = sendAfter;
return (this);
}
/*******************************************************************************
** Getter for linkedTableName.
*******************************************************************************/
public String getLinkedTableName()
{
return (linkedTableName);
}
/*******************************************************************************
** Fluent setter for linkedTableName.
*******************************************************************************/
public EmailApiRequest withLinkedTableName(String linkedTableName)
{
this.linkedTableName = linkedTableName;
return (this);
}
/*******************************************************************************
** Getter for linkedRecordId.
*******************************************************************************/
public String getLinkedRecordId()
{
return (linkedRecordId);
}
/*******************************************************************************
** Fluent setter for linkedRecordId.
*******************************************************************************/
public EmailApiRequest withLinkedRecordId(String linkedRecordId)
{
this.linkedRecordId = linkedRecordId;
return (this);
}
/*******************************************************************************
** Getter for senderQbit.
*******************************************************************************/
public String getSenderQbit()
{
return (senderQbit);
}
/*******************************************************************************
** Fluent setter for senderQbit.
*******************************************************************************/
public EmailApiRequest withSenderQbit(String senderQbit)
{
this.senderQbit = senderQbit;
return (this);
}
/*******************************************************************************
** Getter for senderProcess.
*******************************************************************************/
public String getSenderProcess()
{
return (senderProcess);
}
/*******************************************************************************
** Fluent setter for senderProcess.
*******************************************************************************/
public EmailApiRequest withSenderProcess(String senderProcess)
{
this.senderProcess = senderProcess;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public Map<String, Serializable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public EmailApiRequest withVariables(Map<String, Serializable> variables)
{
this.variables = variables;
return (this);
}
/*******************************************************************************
** Getter for recordVariables.
*******************************************************************************/
public Map<String, RecordVariable> getRecordVariables()
{
return (recordVariables);
}
/*******************************************************************************
** Fluent setter for recordVariables.
*******************************************************************************/
public EmailApiRequest withRecordVariables(Map<String, RecordVariable> recordVariables)
{
this.recordVariables = recordVariables;
return (this);
}
/*******************************************************************************
** Append to recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withTo(String... addresses)
{
to.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append cc recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withCc(String... addresses)
{
cc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append bcc recipients. Sending still enforces category and PHI recipient rules.
*******************************************************************************/
public EmailApiRequest withBcc(String... addresses)
{
bcc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Link the message to an application record.
*******************************************************************************/
public EmailApiRequest withLinkedRecord(String table, Object id)
{
linkedTableName = table;
linkedRecordId = id == null ? null : String.valueOf(id);
return (this);
}
/*******************************************************************************
** Identify the producing qbit and process.
*******************************************************************************/
public EmailApiRequest withSender(String qbit, String process)
{
senderQbit = qbit;
senderProcess = process;
return (this);
}
/*******************************************************************************
** Bind one variable; template schema and classification checks still apply.
*******************************************************************************/
public EmailApiRequest withVariable(String name, Serializable value)
{
variables.put(name, value);
return (this);
}
/*******************************************************************************
** Bind record provenance without reading or snapshotting the record.
*******************************************************************************/
public EmailApiRequest withRecordVariable(String name, String table, Serializable pk)
{
recordVariables.put(name, new RecordVariable(table, pk));
return (this);
}
/*******************************************************************************
** A record reference; values are resolved and classified by the sending service.
*******************************************************************************/
public record RecordVariable(String table, Serializable id)
{
}
}
@@ -1,39 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.util.List;
/*******************************************************************************
** Immutable enqueue result with plain refusal reasons.
*******************************************************************************/
public record EmailApiResult(Integer messageId, EmailApiStatus status, List<String> reasons)
{
/*******************************************************************************
** Copy reasons so callers cannot mutate an outcome.
*******************************************************************************/
public EmailApiResult
{
reasons = List.copyOf(reasons);
}
}
@@ -1,29 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** An enabled provider sender address; no provider credentials.
*******************************************************************************/
public record EmailApiSenderAddress(Integer id, String address, String displayName, boolean isDefault)
{
}
@@ -1,33 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** Enqueue outcomes. QUEUED acknowledges storage, not delivery.
*******************************************************************************/
public enum EmailApiStatus
{
QUEUED,
DUPLICATE,
SUPPRESSED,
REFUSED
}
@@ -1,319 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
** A create-once template definition. Existing templates and versions are never overwritten.
** New templates are not PHI approved; approval remains a separately authorized operation.
*******************************************************************************/
public final class EmailApiTemplate
{
private String name;
private String label;
private EmailApiCategory category;
private String kind = "MARKDOWN";
private String subject;
private String body;
private String textBody;
private String variablesSchema;
private List<EmailApiVariable> variables = new ArrayList<>();
private String sampleVariables;
private String ownerQbit;
private String editPermissionName;
/*******************************************************************************
** Getter for name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Fluent setter for name.
*******************************************************************************/
public EmailApiTemplate withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Fluent setter for label.
*******************************************************************************/
public EmailApiTemplate withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for category.
*******************************************************************************/
public EmailApiCategory getCategory()
{
return (category);
}
/*******************************************************************************
** Fluent setter for category.
*******************************************************************************/
public EmailApiTemplate withCategory(EmailApiCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for kind.
*******************************************************************************/
public String getKind()
{
return (kind);
}
/*******************************************************************************
** Fluent setter for kind.
*******************************************************************************/
public EmailApiTemplate withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Fluent setter for subject.
*******************************************************************************/
public EmailApiTemplate withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Getter for body.
*******************************************************************************/
public String getBody()
{
return (body);
}
/*******************************************************************************
** Fluent setter for body.
*******************************************************************************/
public EmailApiTemplate withBody(String body)
{
this.body = body;
return (this);
}
/*******************************************************************************
** Getter for textBody.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** Fluent setter for textBody.
*******************************************************************************/
public EmailApiTemplate withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** Getter for variablesSchema.
*******************************************************************************/
public String getVariablesSchema()
{
return (variablesSchema);
}
/*******************************************************************************
** Fluent setter for variablesSchema.
*******************************************************************************/
public EmailApiTemplate withVariablesSchema(String variablesSchema)
{
this.variablesSchema = variablesSchema;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public List<EmailApiVariable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public EmailApiTemplate withVariables(List<EmailApiVariable> variables)
{
this.variables = variables;
return (this);
}
/*******************************************************************************
** Getter for sampleVariables.
*******************************************************************************/
public String getSampleVariables()
{
return (sampleVariables);
}
/*******************************************************************************
** Fluent setter for sampleVariables.
*******************************************************************************/
public EmailApiTemplate withSampleVariables(String sampleVariables)
{
this.sampleVariables = sampleVariables;
return (this);
}
/*******************************************************************************
** Getter for ownerQbit.
*******************************************************************************/
public String getOwnerQbit()
{
return (ownerQbit);
}
/*******************************************************************************
** Fluent setter for ownerQbit.
*******************************************************************************/
public EmailApiTemplate withOwnerQbit(String ownerQbit)
{
this.ownerQbit = ownerQbit;
return (this);
}
/*******************************************************************************
** Getter for editPermissionName.
*******************************************************************************/
public String getEditPermissionName()
{
return (editPermissionName);
}
/*******************************************************************************
** Fluent setter for editPermissionName.
*******************************************************************************/
public EmailApiTemplate withEditPermissionName(String editPermissionName)
{
this.editPermissionName = editPermissionName;
return (this);
}
/*******************************************************************************
** Append a typed variable declaration. Use this or variablesSchema, not both.
*******************************************************************************/
public EmailApiTemplate withVariable(EmailApiVariable variable)
{
variables.add(variable);
return (this);
}
/*******************************************************************************
** Append a variable declaration using the template schema vocabulary.
*******************************************************************************/
public EmailApiTemplate withVariable(String name, String label, String type, boolean required, String classification)
{
return (withVariable(new EmailApiVariable(name, label, type, required, classification)));
}
}
@@ -1,30 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.api;
/*******************************************************************************
** Template variable declaration. Types: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN,
** URL, LIST, RECORD. Classification: PUBLIC, CONFIDENTIAL, PII, PHI or null.
*******************************************************************************/
public record EmailApiVariable(String name, String label, String type, boolean required, String classification)
{
}
@@ -1,121 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.app;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.EmailQBitConfig;
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;
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.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Email app with viewer-scoped dashboard widgets and the five main tables in navigation.
** Versions and attempts are child lists, and processes are record actions.
*******************************************************************************/
public final class EmailApp
{
public static final String HOME_WIDGET = "emailHome";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailApp()
{
}
/*******************************************************************************
** Standard home and record preview widgets.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(
widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class),
widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class)));
}
/*******************************************************************************
** The app, with the main tables as children.
*******************************************************************************/
public static QAppMetaData app(EmailQBitConfig config, Map<String, QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData()
.withName(config.getAppName())
.withLabel(config.getAppLabel())
.withIcon(new QIcon("mail"))
.withPermissionRules(EmailTables.permissionRules())
.withWidgets(List.of(HOME_WIDGET));
for(String name : EmailTables.MAIN)
{
if(tables.containsKey(name))
{
app.withChild(tables.get(name));
}
}
return (app);
}
/*******************************************************************************
** Home renderer.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(EmailHome.overview()));
}
}
/*******************************************************************************
** Full-width widgets follow the Alerts home pattern.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, String type, Class<?> renderer)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type).withGridColumns(12).withIsCard(false)
.withShowReloadButton(true).withCodeReference(new QCodeReference(renderer)).withPermissionRules(EmailTables.permissionRules()));
}
}
@@ -1,214 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Real viewer-scoped data. Counts preserve record security without sampling;
** inline bar charts and rows use the same theme-neutral styles as Webhooks.
*******************************************************************************/
public final class EmailHome
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHome()
{
}
/*******************************************************************************
** Counts and recent messages, without loading bodies or variable snapshots.
*******************************************************************************/
public static RawHTML overview() throws QException
{
Instant now = EmailRuntime.clock().instant();
List<Integer> totals = new ArrayList<>();
String[] fields = {"sentDate", "deliveredDate", "deadLetterDate", "bouncedDate", "complainedDate"};
for(int i = 0; i < fields.length; i++)
{
String eventType = switch(i)
{
case 1 -> "DELIVERED";
case 3 -> "BOUNCED";
case 4 -> "COMPLAINED";
default -> null;
};
String field = eventType == null ? fields[i] : "occurredDate";
QQueryFilter filter = since(field, now.minus(i < 3 ? 1 : 7, ChronoUnit.DAYS))
.withCriteria(field, QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
if(eventType != null)
{
filter.withCriteria("eventType", QCriteriaOperator.EQUALS, eventType);
}
totals.add(count(eventType == null ? EmailTables.MESSAGE : EmailTables.EVENT, filter));
}
totals.add(count(EmailTables.SUPPRESSION, new QQueryFilter()));
List<QRecord> messages = query(EmailTables.MESSAGE, new QQueryFilter().withOrderBy(new QFilterOrderBy("createDate", false))
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(20), List.of("id", "createDate", "toAddresses", "subject", "phiApproved", "status"));
boolean admin = EmailPermissionChecker.has(EmailPermissions.ADMIN);
List<QRecord> providers = admin ? query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)),
List.of("id", "label", "name", "enabled", "healthStatus", "lastSuccessDate", "lastFailureMessage", "maxPerMinute")) : List.of();
for(QRecord provider : providers)
{
Integer limit = provider.getValueInteger("maxPerMinute");
if(limit != null)
{
int used = count(EmailTables.ATTEMPT, since("startDate", now.minusSeconds(60))
.withCriteria("startDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withCriteria("providerId", QCriteriaOperator.EQUALS, provider.getValue("id")));
provider.setValue("headroom", Math.max(0, limit - used));
}
}
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions())));
}
/*******************************************************************************
** Twenty-four UTC hour buckets, counting successful and failed attempts.
*******************************************************************************/
public static ChartData hourly() throws QException
{
Instant start = EmailRuntime.clock().instant().truncatedTo(ChronoUnit.HOURS).minus(23, ChronoUnit.HOURS);
List<String> labels = new ArrayList<>();
List<Number> sent = new ArrayList<>();
List<Number> failed = new ArrayList<>();
for(int i = 0; i < 24; i++)
{
Instant hour = start.plus(i, ChronoUnit.HOURS);
labels.add(DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH).withZone(ZoneOffset.UTC).format(hour));
QQueryFilter filter = since("endDate", hour).withCriteria("endDate", QCriteriaOperator.LESS_THAN, hour.plus(1, ChronoUnit.HOURS));
sent.add(count(EmailTables.ATTEMPT, filter.clone().withCriteria("outcome", QCriteriaOperator.EQUALS, "SUCCEEDED")));
failed.add(count(EmailTables.ATTEMPT, filter.clone().withCriteria("outcome", QCriteriaOperator.IN, List.of("TRANSIENT_FAILURE", "PROVIDER_FAILURE", "PERMANENT_FAILURE", "LEASE_EXPIRED"))));
}
return (chart(labels, List.of(dataset("Sent", sent, "#16a34a"), dataset("Failed", failed, "#dc2626")), "No completed attempts in the last 24 hours. Counts use UTC hours."));
}
/*******************************************************************************
** Suppression rows created per UTC day and reason, for thirty days.
*******************************************************************************/
public static ChartData suppressions() throws QException
{
Instant start = EmailRuntime.clock().instant().truncatedTo(ChronoUnit.DAYS).minus(29, ChronoUnit.DAYS);
List<String> labels = new ArrayList<>();
List<ChartData.Data.Dataset> datasets = new ArrayList<>();
List<String> reasons = List.of("HARD_BOUNCE", "COMPLAINT", "UNSUBSCRIBE", "MANUAL");
List<String> colors = List.of("#dc2626", "#d97706", "#2563eb", "#6b7280");
for(int r = 0; r < reasons.size(); r++)
{
List<Number> counts = new ArrayList<>();
for(int i = 0; i < 30; i++)
{
Instant day = start.plus(i, ChronoUnit.DAYS);
if(r == 0)
{
labels.add(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH).withZone(ZoneOffset.UTC).format(day));
}
counts.add(count(EmailTables.SUPPRESSION, since("createdDate", day).withCriteria("createdDate", QCriteriaOperator.LESS_THAN, day.plus(1, ChronoUnit.DAYS))
.withCriteria("reason", QCriteriaOperator.EQUALS, reasons.get(r))));
}
datasets.add(dataset(com.kingsrook.qbits.email.metadata.EmailEnums.SuppressionReason.valueOf(reasons.get(r)).getPossibleValueLabel(), counts, colors.get(r)));
}
return (chart(labels, datasets, "No new suppressions in the last 30 days. Counts use UTC dates."));
}
/*******************************************************************************
** Standard chart with an honest empty-state sublabel.
*******************************************************************************/
private static ChartData chart(List<String> labels, List<ChartData.Data.Dataset> datasets, String empty)
{
ChartData chart = new ChartData().withChartData(new ChartData.Data().withLabels(labels).withDatasets(datasets));
boolean any = datasets.stream().anyMatch(d -> d.getData().stream().anyMatch(n -> n.doubleValue() > 0));
chart.setSublabel(any ? "Real data visible to your session; UTC buckets." : empty);
return (chart);
}
/*******************************************************************************
** A standard chart series.
*******************************************************************************/
private static ChartData.Data.Dataset dataset(String label, List<Number> values, String color)
{
return (new ChartData.Data.Dataset().withLabel(label).withData(values).withColor(color));
}
/*******************************************************************************
** Mask display names and all but the first local-part character.
*******************************************************************************/
public static String mask(String recipients)
{
try
{
String address = jakarta.mail.internet.InternetAddress.parse(recipients)[0].getAddress();
int at = address.lastIndexOf('@');
return (at > 0 ? address.charAt(0) + "***" + address.substring(at) : "[masked]");
}
catch(Exception e)
{
return ("[masked]");
}
}
/*******************************************************************************
** Date lower bound, always used with viewer-scoped actions.
*******************************************************************************/
private static QQueryFilter since(String field, Instant since)
{
return (new QQueryFilter().withCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
}
/*******************************************************************************
** Exact count as the viewer, without authorized scopes.
*******************************************************************************/
private static int count(String table, QQueryFilter filter) throws QException
{
return (new CountAction().execute(new CountInput(table).withFilter(filter)).getCount());
}
/*******************************************************************************
** Select only home fields; row and field security remain active.
*******************************************************************************/
private static List<QRecord> query(String table, QQueryFilter filter, List<String> fields) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(new java.util.HashSet<>(fields)).withShouldFetchHeavyFields(true)).getRecords());
}
}
@@ -1,243 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.metadata.EmailEnums;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Inline styles survive the UI sanitizer. Grids collapse into labelled cards;
** bars shrink with their container and all ordinary text inherits the theme.
*******************************************************************************/
public final class EmailHomeView
{
private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box;";
private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;";
private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);";
private static final String FAIL = "#d64545";
private static final String OK = "#2e9e5b";
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailHomeView()
{
}
/*******************************************************************************
** One ordered home with real, viewer-scoped data supplied by EmailHome.
*******************************************************************************/
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions)
{
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
html.append("<div style=\"" + WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr));gap:12px\">");
List<String> labels = List.of("Sent (24 h)", "Delivered (24 h)", "Dead letters (24 h)", "Bounced (7 d)", "Complaints (7 d)", "Suppressed addresses");
for(int i = 0; i < labels.size(); i++)
{
html.append("<div style=\"").append(CARD).append("padding:12px\"><div style=\"font-size:1.6rem;font-weight:600;line-height:1.2;")
.append(i >= 2 && i <= 4 && totals.get(i) > 0 ? "color:" + FAIL : "color:inherit").append("\">")
.append(String.format(Locale.ENGLISH, "%,d", totals.get(i))).append("</div><div style=\"font-size:.85rem;opacity:.75\">")
.append(labels.get(i)).append("</div></div>");
}
html.append("</div>");
bars(html, "Sends per hour, last 24 hours", hourly);
providers(html, providers, admin);
messages(html, messages);
bars(html, "Suppressions added, last 30 days", suppressions);
return (html.append("</div>").toString());
}
/*******************************************************************************
** A bounded stacked bar chart, including exact values in each bar's title.
*******************************************************************************/
private static void bars(StringBuilder html, String title, ChartData chart)
{
section(html, title);
var data = chart.getChartData();
int max = 0;
for(int i = 0; i < data.getLabels().size(); i++)
{
int total = 0;
for(var dataset : data.getDatasets())
{
total += dataset.getData().get(i).intValue();
}
max = Math.max(max, total);
}
if(max == 0)
{
html.append("<p style=\"margin:0;opacity:.75\">").append(esc(chart.getSublabel())).append("</p></section>");
return;
}
html.append("<div style=\"font-size:.75rem;opacity:.75\">Peak ").append(max).append("</div>")
.append("<div role=\"img\" aria-label=\"").append(esc(title)).append("; stacked counts, UTC\" style=\"")
.append(WRAP).append("display:flex;align-items:flex-end;gap:2px;height:140px;width:100%\">");
for(int i = 0; i < data.getLabels().size(); i++)
{
StringBuilder description = new StringBuilder(data.getLabels().get(i));
for(var dataset : data.getDatasets())
{
description.append("; ").append(dataset.getLabel()).append(": ").append(dataset.getData().get(i));
}
html.append("<div title=\"").append(esc(description.toString())).append("\" style=\"flex:1 1 0;min-width:0;height:100%;display:flex;flex-direction:column-reverse\">");
for(var dataset : data.getDatasets())
{
html.append("<div style=\"background:").append(dataset.getColor()).append(";height:")
.append(String.format(Locale.ROOT, "%.3f", 100.0 * dataset.getData().get(i).doubleValue() / max)).append("%\"></div>");
}
html.append("</div>");
}
html.append("</div><div style=\"display:flex;justify-content:space-between;gap:8px;min-width:0;font-size:.75rem;opacity:.75;margin-top:4px\">");
for(int i : new int[] {0, data.getLabels().size() / 2, data.getLabels().size() - 1})
{
html.append("<span>").append(esc(data.getLabels().get(i))).append("</span>");
}
html.append("</div><div style=\"display:flex;flex-wrap:wrap;gap:8px 16px;min-width:0;font-size:.8rem;margin-top:8px\">");
for(var dataset : data.getDatasets())
{
html.append("<span><span style=\"display:inline-block;width:.7rem;height:.7rem;margin-right:5px;background:")
.append(dataset.getColor()).append("\"></span>").append(esc(dataset.getLabel())).append("</span>");
}
html.append("<span style=\"opacity:.75\">UTC</span></div></section>");
}
/*******************************************************************************
** Provider health is admin-only; disabled providers never appear healthy.
*******************************************************************************/
private static void providers(StringBuilder html, List<QRecord> providers, boolean admin)
{
section(html, "Provider health");
if(!admin || providers.isEmpty())
{
html.append("<p style=\"margin:0;opacity:.75\">").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("</p>");
}
for(QRecord provider : providers)
{
String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled"
: "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown";
String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080";
html.append("<div style=\"").append(ROW).append("\">")
.append(cell("Provider", "<strong>" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "</strong><br><span style=\"color:" + color + "\">" + status + "</span>"))
.append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate")))))
.append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None"))))
.append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("</div>");
}
html.append("</section>");
}
/*******************************************************************************
** The whole row is a link. Labelled columns stack as cards on narrow screens.
*******************************************************************************/
private static void messages(StringBuilder html, List<QRecord> messages)
{
section(html, "Recent messages");
if(messages.isEmpty())
{
html.append("<p style=\"margin:0;opacity:.75\">No messages yet.</p>");
}
for(QRecord message : messages)
{
String subject = Boolean.TRUE.equals(message.getValueBoolean("phiApproved")) || "[encrypted]".equals(message.getValueString("subject"))
? "Secure message" : value(message, "subject", "Message " + message.getValueInteger("id"));
String status = message.getValueString("status");
html.append("<a href=\"/app/").append(EmailTables.MESSAGE).append('/').append(message.getValueInteger("id"))
.append("\" style=\"").append(ROW).append("color:inherit;text-decoration:none\">")
.append(cell("When", esc(when(message.getValueInstant("createDate")))))
.append(cell("To", esc(EmailHome.mask(message.getValueString("toAddresses")))))
.append(cell("Subject", esc(subject)))
.append(cell("Status", esc(status == null ? "Unknown" : EmailEnums.MessageStatus.valueOf(status).getPossibleValueLabel())))
.append("</a>");
}
html.append("</section>");
}
/*******************************************************************************
** One bordered section, using the surrounding theme's text colour.
*******************************************************************************/
private static void section(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 12px;font-size:1rem;font-weight:600\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
** A labelled, shrinkable cell that remains understandable when stacked.
*******************************************************************************/
private static String cell(String label, String content)
{
return ("<div style=\"" + WRAP + "\"><span style=\"display:block;font-size:.75rem;opacity:.65\">" + esc(label) + "</span> " + content + "</div>");
}
/*******************************************************************************
** Compact UTC dates, with an honest missing value.
*******************************************************************************/
private static String when(Instant instant)
{
return (instant == null ? "None" : WHEN.format(instant));
}
/*******************************************************************************
** Blank fields use a human-readable fallback.
*******************************************************************************/
private static String value(QRecord row, String field, String fallback)
{
String value = row.getValueString(field);
return (value == null || value.isBlank() ? fallback : value);
}
/*******************************************************************************
** Escape every record-sourced value before inserting HTML.
*******************************************************************************/
private static String esc(String value)
{
return (EmailTemplateEngine.escape(value));
}
}
@@ -1,135 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.bridge;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Adapts core messaging calls to durable asynchronous outbox inserts.
*******************************************************************************/
public class EmailOutboxMessagingProvider implements MessagingProviderInterface
{
/*******************************************************************************
** Distinct provider type.
*******************************************************************************/
@Override
public String getType()
{
return (EmailOutboxMessagingProviderMetaData.TYPE);
}
/*******************************************************************************
** Map core parties and alternatives; refuse unsupported attachments.
*******************************************************************************/
@Override
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
{
if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty())
{
throw (new QException("Email attachments are not supported in part 1"));
}
EmailRequest request = input instanceof EmailTemplatedMessageInput templated && templated.getEmailRequest() != null
? templated.getEmailRequest() : new EmailRequest().withSubject(input.getSubject());
parties(request, input.getTo(), EmailPartyRole.TO);
parties(request, input.getFrom(), EmailPartyRole.FROM);
if(input.getContentList() != null)
{
for(var content : input.getContentList())
{
if(content.getContentRole() == EmailContentRole.HTML)
{
request.withHtmlBody(content.getBody());
}
else if(content.getContentRole() == EmailContentRole.TEXT || content.getContentRole() == null)
{
request.withTextBody(content.getBody());
}
else
{
throw (new QException("Unsupported email content role"));
}
}
}
EmailSendResult result = EmailService.send(request);
if(result.status() == EmailSendResult.Status.REFUSED)
{
throw (new QException(String.join("; ", result.reasons())));
}
return (new SendMessageOutput());
}
/*******************************************************************************
** Walk nested MultiParty inputs, inheriting a parent role when omitted.
*******************************************************************************/
private static void parties(EmailRequest request, Party party, EmailPartyRole inherited) throws QException
{
if(party == null)
{
return;
}
EmailPartyRole role = party.getRole() instanceof EmailPartyRole explicit ? explicit : inherited;
if(party instanceof MultiParty multi)
{
if(multi.getPartyList() != null)
{
for(Party child : multi.getPartyList())
{
parties(request, child, role);
}
}
return;
}
String address = party.getAddress();
try
{
if(party.getLabel() != null)
{
address = new InternetAddress(address, party.getLabel(), "UTF-8").toString();
}
}
catch(Exception e)
{
throw (new QException("Invalid email party"));
}
switch(role)
{
case TO -> request.withTo(address);
case CC -> request.withCc(address);
case BCC -> request.withBcc(address);
case FROM -> request.withFrom(address);
case REPLY_TO -> request.withReplyTo(address);
default -> throw (new QException("Unsupported email party role"));
}
}
}
@@ -1,47 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.bridge;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
/*******************************************************************************
** Core messaging provider type registered independently of EMAIL and SES.
*******************************************************************************/
public class EmailOutboxMessagingProviderMetaData extends QMessagingProviderMetaData
{
public static final String TYPE = "QBIT_EMAIL";
static
{
QMessagingProviderDispatcher.registerMessagingProvider(new EmailOutboxMessagingProvider());
}
/*******************************************************************************
** The bridge type.
*******************************************************************************/
public EmailOutboxMessagingProviderMetaData()
{
setType(TYPE);
}
}
@@ -1,52 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.bridge;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
/*******************************************************************************
** Optional typed template payload for core SendMessageAction bridge callers.
** Plain SendMessageInput remains an ad-hoc message for backwards compatibility.
*******************************************************************************/
public class EmailTemplatedMessageInput extends SendMessageInput
{
private EmailRequest emailRequest;
/*******************************************************************************
** Set schema variables and record references for the normal validated API.
*******************************************************************************/
public EmailTemplatedMessageInput withEmailRequest(EmailRequest request)
{
emailRequest = request;
return (this);
}
/*******************************************************************************
** The caller's template request; party fields can still come from core input.
*******************************************************************************/
public EmailRequest getEmailRequest()
{
return (emailRequest);
}
}
@@ -1,83 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.delivery;
import java.util.List;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
/*******************************************************************************
** Optional alert sink integration with content-free signals.
*******************************************************************************/
public final class EmailAlerts
{
public static final String COMPLAINT = "email.complaint";
public static final String UNHEALTHY = "email.provider.unhealthy";
public static final String DEAD_LETTER = "email.message.dead-letter";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailAlerts()
{
}
/*******************************************************************************
** Declare alert types using RC.5 dotted-name syntax.
*******************************************************************************/
public static List<QAlertTypeMetaData> types()
{
return (List.of(new QAlertTypeMetaData().withName(COMPLAINT).withLabel("Email complaints").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(EmailTables.EVENT).withVisibilityPermission("email.view").withSourceName("email"),
new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
.withSubjectTableName(EmailTables.PROVIDER).withVisibilityPermission("email.admin").withSourceName("email"),
new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Email dead letters").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(EmailTables.MESSAGE).withVisibilityPermission("email.view").withSourceName("email")));
}
/*******************************************************************************
** Raise without addresses, provider diagnostics or content.
*******************************************************************************/
public static void raise(String type, Integer providerId)
{
String key = "provider:" + providerId + (DEAD_LETTER.equals(type) ? ":" + EmailRuntime.clock().instant().getEpochSecond() / 3600 : "");
if(COMPLAINT.equals(type))
{
key = "complaints:" + EmailRuntime.clock().instant().getEpochSecond() / 86400;
}
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(COMPLAINT.equals(type) ? "More than three email complaints in 24 hours" : UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
.withSummary("Review the Email tables for status and retry actions."));
}
/*******************************************************************************
** Resolve on recovery; core guards a missing or failing sink.
*******************************************************************************/
public static void recovered(Integer providerId)
{
QAlerts.resolve(UNHEALTHY, "provider:" + providerId, "An email attempt succeeded");
}
}
@@ -1,520 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.delivery;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.transport.EmailEnvelope;
import com.kingsrook.qbits.email.transport.EmailProviderSettings;
import com.kingsrook.qbits.email.transport.EmailTransportException;
import com.kingsrook.qbits.email.transport.EmailTransportRegistry;
import com.kingsrook.qbits.email.transport.EmailTransportResult;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Durable at-least-once delivery with unique attempt claims and database leases.
*******************************************************************************/
public class EmailDispatcher
{
private static final QLogger LOG = QLogger.getLogger(EmailDispatcher.class);
public static final List<String> DUE = List.of("QUEUED", "RETRY_WAIT");
public static final List<String> FINAL = List.of("SENT", "DELIVERED", "DEAD_LETTER", "BOUNCED", "COMPLAINED", "SUPPRESSED", "CANCELLED");
private final EmailTransportRegistry transports;
private final String runner = EmailRuntime.nodeId() + "/" + UUID.randomUUID();
/*******************************************************************************
** Default delivery transports.
*******************************************************************************/
public EmailDispatcher()
{
this(EmailRuntime.config() == null ? new EmailTransportRegistry() : EmailRuntime.config().newTransportRegistry());
}
/*******************************************************************************
** Inject transport registry for host extensions and tests.
*******************************************************************************/
public EmailDispatcher(EmailTransportRegistry transports)
{
this.transports = transports;
}
/*******************************************************************************
** One message delivery result.
*******************************************************************************/
public enum SendResult
{
SENT,
RETRY_WAIT,
DEAD_LETTER,
PROVIDER_FAILURE,
NOT_SENT
}
/*******************************************************************************
** Counts from a dispatch pass.
*******************************************************************************/
public record RunSummary(int sent, int failed, int skipped)
{
}
/*******************************************************************************
** A database claim, fully committed before network I/O.
*******************************************************************************/
private record Claim(QRecord message, QRecord provider, QRecord attempt)
{
}
/*******************************************************************************
** Dispatch the configured batch.
*******************************************************************************/
public RunSummary runOnce() throws QException
{
return (run(null));
}
/*******************************************************************************
** Dispatch due message ids, or all due messages when null.
*******************************************************************************/
public RunSummary run(List<Integer> ids) throws QException
{
if(EmailRuntime.config() == null || (ids != null && ids.isEmpty()))
{
return (new RunSummary(0, 0, 0));
}
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT", "SENDING"))
.withCriteria(new QFilterCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant()))
.withOrderBy(new QFilterOrderBy("id")).withLimit(EmailRuntime.config().getBatchSize());
if(ids != null)
{
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<>(ids)));
}
int sent = 0;
int failed = 0;
int skipped = 0;
Integer afterId = 0;
Set<Integer> failedProviders = new HashSet<>();
while(sent + failed < EmailRuntime.config().getBatchSize())
{
QQueryFilter pageFilter = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, afterId));
List<QRecord> page = SystemWrites.query(EmailTables.MESSAGE, pageFilter);
if(page.isEmpty())
{
break;
}
for(QRecord row : page)
{
afterId = row.getValueInteger("id");
if(failedProviders.contains(row.getValueInteger("providerId")))
{
skipped++;
continue;
}
try
{
SendResult result = attempt(afterId, false);
switch(result)
{
case SENT -> sent++;
case RETRY_WAIT, DEAD_LETTER -> failed++;
case PROVIDER_FAILURE ->
{
failed++;
failedProviders.add(row.getValueInteger("providerId"));
}
default -> skipped++;
}
}
catch(QException | RuntimeException e)
{
failed++;
LOG.warn("Email attempt failed", logPair("messageId", afterId));
}
if(sent + failed >= EmailRuntime.config().getBatchSize())
{
break;
}
}
}
return (new RunSummary(sent, failed, skipped));
}
/*******************************************************************************
** Manual send bypasses due time but preserves rate limits, health and leases.
*******************************************************************************/
public SendResult sendNow(Integer id) throws QException
{
SendResult result = attempt(id, true);
return (result == SendResult.PROVIDER_FAILURE ? SendResult.RETRY_WAIT : result);
}
/*******************************************************************************
** Claim, send outside the transaction, then persist the fenced result.
*******************************************************************************/
protected SendResult attempt(Integer id, boolean now) throws QException
{
Claim claim = claim(id, now);
if(claim == null)
{
return (SendResult.NOT_SENT);
}
EmailTransportResult result = null;
EmailTransportException error = null;
try
{
if(Boolean.TRUE.equals(claim.message().getValueBoolean("phiApproved")))
{
com.kingsrook.qbits.email.template.EmailTemplateRenderer.checkProvider(claim.provider());
}
result = transports.get(claim.provider().getValueString("kind")).send(envelope(claim.message()), new EmailProviderSettings(claim.provider(), EmailRuntime.config()));
}
catch(EmailTransportException e)
{
error = e;
}
catch(RuntimeException e)
{
error = new EmailTransportException("Transport temporarily unavailable", false);
}
return (finish(claim, result, error));
}
/*******************************************************************************
** Unique attempt insertion under a short provider lock, including rate checks.
*******************************************************************************/
private Claim claim(Integer id, boolean immediate) throws QException
{
EmailQBitConfig config = EmailRuntime.config();
QRecord initial = EmailStore.get(EmailTables.MESSAGE, id, null);
if(initial == null || config == null)
{
return (null);
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
QRecord provider = EmailStore.lockProvider(initial.getValueInteger("providerId"), tx);
QRecord message = EmailStore.get(EmailTables.MESSAGE, id, tx);
Instant time = EmailRuntime.clock().instant();
if(provider == null || message == null || !Boolean.TRUE.equals(provider.getValueBoolean("enabled")))
{
return (null);
}
String status = message.getValueString("status");
int previous = message.getValueInteger("attemptCount") == null ? 0 : message.getValueInteger("attemptCount");
if("SENDING".equals(status))
{
Instant lease = message.getValueInstant("leaseExpiresDate");
if(lease != null && lease.isAfter(time))
{
return (null);
}
expire(message, tx, time);
if(budgetAttempts(id, previous, tx) >= config.getMaxAttempts())
{
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "DEAD_LETTER")
.withValue("deadLetterDate", time).withValue("finishedDate", time).withValue("leaseExpiresDate", null).withValue("nextAttemptDate", null).withValue("lastError", "Attempt lease expired"), tx);
tx.commit();
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, provider.getValueInteger("id")));
return (null);
}
}
else if(!DUE.contains(status))
{
return (null);
}
if(!immediate && ((message.getValueInstant("sendAfter") != null && message.getValueInstant("sendAfter").isAfter(time))
|| (message.getValueInstant("nextAttemptDate") != null && message.getValueInstant("nextAttemptDate").isAfter(time))))
{
return (null);
}
if(limited(provider, tx, time))
{
return (null);
}
Instant lease = time.plusSeconds(config.getLeaseSeconds());
QRecord attempt = new QRecord().withValue("messageId", id).withValue("providerId", provider.getValue("id"))
.withValue("attemptNumber", previous + 1).withValue("outcome", "IN_FLIGHT").withValue("claimedBy", runner)
.withValue("startDate", time).withValue("leaseExpiresDate", lease);
for(var field : config.getSecurityFields())
{
attempt.setValue(field.getName(), message.getValue(field.getName()));
}
QRecord inserted = SystemWrites.insert(EmailTables.ATTEMPT, List.of(attempt), tx).getRecords().get(0);
if(inserted.getValueInteger("id") == null || (inserted.getErrors() != null && !inserted.getErrors().isEmpty()))
{
return (null);
}
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "SENDING").withValue("attemptCount", previous + 1)
.withValue("leaseExpiresDate", lease).withValue("nextAttemptDate", lease), tx);
tx.commit();
return (new Claim(message, provider, inserted));
}
}
/*******************************************************************************
** Shared rate and unhealthy-provider probe budgets, counted from attempts.
*******************************************************************************/
private static boolean limited(QRecord provider, QBackendTransaction transaction, Instant now) throws QException
{
for(String field : List.of("maxPerMinute", "maxPerDay"))
{
Integer limit = provider.getValueInteger(field);
Instant after = now.minusSeconds("maxPerMinute".equals(field) ? 60 : 86400);
if(limit != null && SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("providerId", provider.getValueInteger("id"))
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN, after)), transaction) >= limit)
{
return (true);
}
}
if("UNHEALTHY".equals(provider.getValueString("healthStatus")))
{
return (SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("providerId", provider.getValueInteger("id"))
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN, now.minusSeconds(300))), transaction) > 0);
}
return (false);
}
/*******************************************************************************
** Mark the abandoned attempt before retrying.
*******************************************************************************/
private static void expire(QRecord message, QBackendTransaction tx, Instant now) throws QException
{
for(QRecord attempt : SystemWrites.query(EmailTables.ATTEMPT, EmailStore.equal("messageId", message.getValueInteger("id"))
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "IN_FLIGHT")), tx))
{
EmailStore.update(EmailTables.ATTEMPT, new QRecord().withValue("id", attempt.getValue("id")).withValue("outcome", "LEASE_EXPIRED")
.withValue("endDate", now).withValue("error", "Attempt lease expired"), tx);
}
}
/*******************************************************************************
** Fence late completions by attempt number and persist outcome atomically.
*******************************************************************************/
private SendResult finish(Claim claim, EmailTransportResult result, EmailTransportException error) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
Integer providerId = claim.provider().getValueInteger("id");
QRecord provider = EmailStore.lockProvider(providerId, tx);
QRecord message = EmailStore.get(EmailTables.MESSAGE, claim.message().getValueInteger("id"), tx);
int number = claim.attempt().getValueInteger("attemptNumber");
if(message == null || !"SENDING".equals(message.getValueString("status")) || !Integer.valueOf(number).equals(message.getValueInteger("attemptCount")))
{
return (SendResult.NOT_SENT);
}
Instant now = EmailRuntime.clock().instant();
boolean success = error == null && result != null;
boolean permanent = error != null && error.getPermanent();
boolean providerLevel = error != null && error.getProviderLevel();
String safeError = error == null ? null : permanent ? "Provider rejected the message" : "Provider temporarily unavailable";
if(providerLevel)
{
safeError = "Credentials missing or refused";
}
if(error != null && "Gmail API sending is specified but not built; use GMAIL_SMTP".equals(error.getMessage()))
{
safeError = "Gmail API sending is specified but not built; use GMAIL_SMTP";
}
EmailStore.update(EmailTables.ATTEMPT, new QRecord().withValue("id", claim.attempt().getValue("id"))
.withValue("outcome", success ? "SUCCEEDED" : providerLevel ? "PROVIDER_FAILURE" : permanent ? "PERMANENT_FAILURE" : "TRANSIENT_FAILURE")
.withValue("endDate", now).withValue("durationMillis", (int) Math.max(0, Duration.between(claim.attempt().getValueInstant("startDate"), now).toMillis()))
.withValue("error", safeError), tx);
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("leaseExpiresDate", null).withValue("lastError", safeError);
SendResult outcome;
if(success)
{
outcome = SendResult.SENT;
update.withValue("status", "SENT").withValue("sentDate", now).withValue("finishedDate", now).withValue("providerMessageId", result.providerMessageId()).withValue("nextAttemptDate", null);
EmailStore.update(EmailTables.PROVIDER, new QRecord().withValue("id", providerId).withValue("healthStatus", "HEALTHY").withValue("lastSuccessDate", now), tx);
}
else
{
boolean dead = permanent || (!providerLevel && budgetAttempts(message.getValueInteger("id"), number, tx) >= EmailRuntime.config().getMaxAttempts());
outcome = dead ? SendResult.DEAD_LETTER : SendResult.RETRY_WAIT;
long seconds = 60L * EmailRuntime.config().retryMinutesAfterAttempt(number);
if(error != null && error.getRetryAfter() != null)
{
seconds = Math.max(1, Math.min(3600, error.getRetryAfter().getSeconds()));
}
update.withValue("status", outcome.name()).withValue("deadLetterDate", dead ? now : null).withValue("finishedDate", dead ? now : null).withValue("nextAttemptDate", dead ? null : now.plusSeconds(seconds));
QRecord health = new QRecord().withValue("id", providerId).withValue("lastFailureDate", now).withValue("lastFailureMessage", safeError);
if(providerLevel || (!permanent && consecutiveFailures(providerId, tx) >= 10))
{
health.withValue("healthStatus", "UNHEALTHY");
}
EmailStore.update(EmailTables.PROVIDER, health, tx);
}
EmailStore.update(EmailTables.MESSAGE, update, tx);
QRecord updatedProvider = EmailStore.get(EmailTables.PROVIDER, providerId, tx);
tx.commit();
if(success && "UNHEALTHY".equals(provider.getValueString("healthStatus")))
{
alert(() -> EmailAlerts.recovered(providerId));
}
if("UNHEALTHY".equals(updatedProvider.getValueString("healthStatus")) && !"UNHEALTHY".equals(provider.getValueString("healthStatus")))
{
alert(() -> EmailAlerts.raise(EmailAlerts.UNHEALTHY, providerId));
}
if(outcome == SendResult.DEAD_LETTER)
{
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, providerId));
}
LOG.info("Email attempt completed", logPair("messageId", message.getValue("id")), logPair("status", outcome.name()));
return (providerLevel ? SendResult.PROVIDER_FAILURE : outcome);
}
}
/*******************************************************************************
** Keep claim numbering monotonic while excluding provider failures from limits.
*******************************************************************************/
private static long budgetAttempts(Integer messageId, int attempts, QBackendTransaction tx) throws QException
{
return (attempts - SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("messageId", messageId)
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "PROVIDER_FAILURE")), tx));
}
/*******************************************************************************
** Optional alerting must never change the result of a committed delivery.
*******************************************************************************/
private static void alert(Runnable notification)
{
try
{
notification.run();
}
catch(RuntimeException e)
{
LOG.warn("Email alert unavailable");
}
}
/*******************************************************************************
** Consecutive completed transient failures; a success or permanent rejection breaks the streak.
*******************************************************************************/
private static int consecutiveFailures(Integer providerId, QBackendTransaction tx) throws QException
{
int failures = 0;
for(QRecord attempt : SystemWrites.query(EmailTables.ATTEMPT, EmailStore.equal("providerId", providerId)
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.NOT_EQUALS, "IN_FLIGHT"))
.withOrderBy(new QFilterOrderBy("endDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10), tx))
{
if(!"TRANSIENT_FAILURE".equals(attempt.getValueString("outcome")))
{
break;
}
failures++;
}
return (failures);
}
/*******************************************************************************
** The persisted alternatives are reused verbatim on each attempt.
*******************************************************************************/
private static EmailEnvelope envelope(QRecord message)
{
return (new EmailEnvelope().withMessageId(message.getValueString("id")).withRfcMessageId(message.getValueString("rfcMessageId")).withFrom(message.getValueString("fromAddress"))
.withTo(split(message.getValueString("toAddresses"))).withCc(split(message.getValueString("ccAddresses"))).withBcc(split(message.getValueString("bccAddresses")))
.withReplyTo(message.getValueString("replyTo")).withSubject(content(message, "subject"))
.withHtmlBody(content(message, "htmlBody"))
.withTextBody(content(message, "textBody"))
.withHeaders(message.getValueString("unsubscribeUrl") == null ? java.util.Map.of() : java.util.Map.of("List-Unsubscribe", "<" + message.getValueString("unsubscribeUrl") + ">", "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")));
}
/*******************************************************************************
** Only policy-marked messages may invoke body decryption. A caller-supplied
** enc:v1 string in ad-hoc content is text, never a decryption capability.
*******************************************************************************/
private static String content(QRecord message, String field)
{
String value = message.getValueString(field);
if(!Boolean.TRUE.equals(message.getValueBoolean("phiApproved")))
{
return (value);
}
if(value != null && !value.startsWith("enc:v1:"))
{
throw (new IllegalArgumentException("Protected email content must be encrypted"));
}
return (com.kingsrook.qbits.email.phi.EmailBodyEncryption.decrypt(value, field, message.getValueString("id")));
}
/*******************************************************************************
** Preserve quoted display names by treating the stored list as one RFC address list.
*******************************************************************************/
private static List<String> split(String value)
{
return (value == null || value.isBlank() ? List.of() : List.of(value));
}
/*******************************************************************************
** Lease recovery and five-minute provider probes share the normal claim path.
*******************************************************************************/
public RunSummary maintain() throws QException
{
Instant now = EmailRuntime.clock().instant();
for(QRecord candidate : SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("status", "SENDING")
.withCriteria(new QFilterCriteria("leaseExpiresDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))))
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailStore.lockProvider(candidate.getValueInteger("providerId"), tx);
QRecord current = EmailStore.get(EmailTables.MESSAGE, candidate.getValueInteger("id"), tx);
if(current == null || !"SENDING".equals(current.getValueString("status")) || current.getValueInstant("leaseExpiresDate").isAfter(now))
{
continue;
}
expire(current, tx, now);
boolean dead = budgetAttempts(current.getValueInteger("id"), current.getValueInteger("attemptCount"), tx) >= EmailRuntime.config().getMaxAttempts();
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", current.getValue("id"))
.withValue("status", dead ? "DEAD_LETTER" : "RETRY_WAIT").withValue("nextAttemptDate", dead ? null : now)
.withValue("leaseExpiresDate", null).withValue("lastError", "Attempt lease expired")
.withValue("deadLetterDate", dead ? now : null).withValue("finishedDate", dead ? now : null), tx);
tx.commit();
if(dead)
{
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, candidate.getValueInteger("providerId")));
}
}
}
return (runOnce());
}
}
@@ -1,399 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.metadata;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
/*******************************************************************************
** Enumerated database values and their display labels.
*******************************************************************************/
public final class EmailEnums
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailEnums()
{
}
/*******************************************************************************
** ProviderKind values.
*******************************************************************************/
public enum ProviderKind implements PossibleValueEnum<String>
{
SMTP,
SES,
POSTMARK,
SENDGRID,
MAILGUN,
GMAIL_SMTP,
GMAIL_API;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** Security values.
*******************************************************************************/
public enum Security implements PossibleValueEnum<String>
{
NONE,
STARTTLS,
TLS;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** MessageStatus values.
*******************************************************************************/
public enum MessageStatus implements PossibleValueEnum<String>
{
QUEUED,
SENDING,
SENT,
DELIVERED,
RETRY_WAIT,
DEAD_LETTER,
BOUNCED,
COMPLAINED,
SUPPRESSED,
CANCELLED;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (this == RETRY_WAIT ? "Waiting to retry" : name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** AttemptOutcome values.
*******************************************************************************/
public enum AttemptOutcome implements PossibleValueEnum<String>
{
IN_FLIGHT,
SUCCEEDED,
TRANSIENT_FAILURE,
PROVIDER_FAILURE,
PERMANENT_FAILURE,
LEASE_EXPIRED;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** HealthStatus values.
*******************************************************************************/
public enum HealthStatus implements PossibleValueEnum<String>
{
HEALTHY,
UNHEALTHY;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** TemplateKind values.
*******************************************************************************/
public enum TemplateKind implements PossibleValueEnum<String>
{
MARKDOWN,
MJML,
HTML;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** VersionStatus values.
*******************************************************************************/
public enum VersionStatus implements PossibleValueEnum<String>
{
DRAFT,
ACTIVE,
RETIRED;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** SuppressionScope values.
*******************************************************************************/
public enum SuppressionScope implements PossibleValueEnum<String>
{
ALL,
TRANSACTIONAL,
NOTIFICATION,
MARKETING,
SECURITY;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** SuppressionReason values.
*******************************************************************************/
public enum SuppressionReason implements PossibleValueEnum<String>
{
HARD_BOUNCE,
COMPLAINT,
UNSUBSCRIBE,
MANUAL;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** InboxStatus values.
*******************************************************************************/
public enum InboxStatus implements PossibleValueEnum<String>
{
PENDING, PROCESSED, FAILED, IGNORED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** EventType values.
*******************************************************************************/
public enum EventType implements PossibleValueEnum<String>
{
DELIVERED, DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** BounceType values.
*******************************************************************************/
public enum BounceType implements PossibleValueEnum<String>
{
HARD, SOFT, UNDETERMINED;
/*******************************************************************************
** Stable stored identifier.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
}
}
}
@@ -1,507 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.metadata;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.transport.EmailProviderSettings;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
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.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Provider and suppression validation shared by UI, API and code.
*******************************************************************************/
public final class EmailTableCustomizers
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTableCustomizers()
{
}
/*******************************************************************************
** Merged values for sparse updates.
*******************************************************************************/
private static QRecord effective(QRecord record, Optional<List<QRecord>> oldRecords)
{
QRecord merged = new QRecord();
if(oldRecords != null && oldRecords.isPresent())
{
oldRecords.get().stream().filter(r -> Objects.equals(r.getValue("id"), record.getValue("id"))).findFirst()
.ifPresent(r -> merged.getValues().putAll(r.getValues()));
}
merged.getValues().putAll(record.getValues());
return (merged);
}
/*******************************************************************************
** Validate a provider without resolving any credentials.
*******************************************************************************/
public static List<String> validateProvider(QRecord row, EmailQBitConfig config)
{
List<String> errors = new ArrayList<>();
String kind = row.getValueString("kind");
if("GMAIL_API".equals(kind))
{
errors.add("Gmail API sending is specified but not built; use GMAIL_SMTP");
}
if(List.of("POSTMARK", "SENDGRID", "MAILGUN", "GMAIL_SMTP").contains(kind == null ? "" : kind)
&& (row.getValueString("secretEnvName") == null || row.getValueString("secretEnvName").isBlank()))
{
errors.add("Provider needs a secret environment name");
}
if("GMAIL_SMTP".equals(kind))
{
if(row.getValueString("usernameEnvName") == null || row.getValueString("usernameEnvName").isBlank())
{
errors.add("Gmail SMTP needs an account address environment name");
}
if(row.getValueString("security") != null && !"STARTTLS".equals(row.getValueString("security")))
{
errors.add("Gmail SMTP requires STARTTLS");
}
}
if("MAILGUN".equals(kind))
{
if(!EmailProviderSettings.validSendingDomain(row.getValueString("sendingDomain")))
{
errors.add("Mailgun needs a valid sending domain");
}
if(!List.of("US", "EU").contains(row.getValueString("region") == null ? "" : row.getValueString("region")))
{
errors.add("Mailgun region must be US or EU");
}
}
if(("SMTP".equals(kind) || "GMAIL_SMTP".equals(kind))
&& !new EmailProviderSettings(row, config).allowsSmtpAuth())
{
errors.add("SMTP sign-in needs STARTTLS or TLS");
}
for(String field : List.of("fromName", "fromAddress", "replyTo"))
{
String value = row.getValueString(field);
if(value != null && (value.contains("\r") || value.contains("\n")))
{
errors.add("Provider sender and reply-to fields must not contain line breaks");
}
}
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
{
String name = row.getValueString(field);
if(name != null && (!EmailQBitConfig.ENV_NAME.matcher(name).matches() || !config.effectiveAllowedEnvNames(kind).contains(name)))
{
errors.add("Credential references must be allowed environment variable names");
}
}
if("SMTP".equals(kind) && (row.getValueString("host") == null || row.getValueString("host").isBlank()
|| row.getValueInteger("port") == null || row.getValueInteger("port") < 1 || row.getValueInteger("port") > 65535))
{
errors.add("SMTP needs a host and a port between 1 and 65535");
}
if("SES".equals(kind) && (row.getValueString("region") == null || row.getValueString("region").isBlank()))
{
errors.add("SES needs a region");
}
if("SES".equals(kind) && (row.getValueString("usernameEnvName") == null) != (row.getValueString("secretEnvName") == null))
{
errors.add("SES needs both credential environment names or neither for the default chain");
}
for(String field : List.of("maxPerMinute", "maxPerDay"))
{
if(row.getValueInteger(field) != null && row.getValueInteger(field) < 1)
{
errors.add("Rate limits must be positive or empty");
}
}
try
{
InternetAddress from = new InternetAddress(row.getValueString("fromAddress"), true);
from.validate();
String domain = row.getValueString("sendingDomain");
if(domain != null && !domain.equalsIgnoreCase(from.getAddress().substring(from.getAddress().lastIndexOf('@') + 1)))
{
errors.add("From address domain must match sending domain");
}
}
catch(Exception e)
{
errors.add("A valid from address is required");
}
return (errors);
}
/*******************************************************************************
** Provider validation and secret-presence display.
*******************************************************************************/
public static class ProviderCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Validate the resulting provider set, including sparse and batch writes.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
if(SystemWrites.active())
{
return (records);
}
List<QRecord> resulting = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(),
input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput insert ? insert.getTransaction()
: input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput update ? update.getTransaction() : null));
for(QRecord record : records)
{
QRecord merged = effective(record, oldRecords);
validateProvider(merged, EmailRuntime.config()).forEach(error -> record.addError(new BadInputStatusMessage(error)));
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
{
String name = record.getValueString(field);
if(name != null && !EmailQBitConfig.ENV_NAME.matcher(name).matches())
{
// An accidentally pasted credential must not be echoed in the failed write.
record.setValue(field, null);
}
}
String sendingDomain = merged.getValueString("sendingDomain");
if(sendingDomain != null && !sendingDomain.isBlank() && merged.getValue("id") != null
&& SystemWrites.query(EmailTables.SENDER, EmailStore.equal("providerId", merged.getValueInteger("id")), null).stream()
.anyMatch(s -> !sendingDomain.equalsIgnoreCase(s.getValueString("address").substring(s.getValueString("address").lastIndexOf('@') + 1))))
{
record.addError(new BadInputStatusMessage("Sender addresses must match the sending domain"));
}
resulting.removeIf(r -> Objects.equals(r.getValue("id"), record.getValue("id")));
resulting.add(merged);
record.setValue("defaultSlot", Boolean.TRUE.equals(merged.getValueBoolean("enabled")) && Boolean.TRUE.equals(merged.getValueBoolean("isDefault")) ? 1 : null);
}
long enabled = resulting.stream().filter(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled"))).count();
long defaults = resulting.stream().filter(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")) && Boolean.TRUE.equals(r.getValueBoolean("isDefault"))).count();
if(enabled > 0 && defaults != 1)
{
records.forEach(r -> r.addError(new BadInputStatusMessage("Exactly one enabled provider must be the default")));
}
return (records);
}
/*******************************************************************************
** Prevent deleting the default while other enabled providers remain.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
{
for(QRecord row : records)
{
if(!SystemWrites.query(EmailTables.MESSAGE, com.kingsrook.qbits.email.support.EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
{
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
}
}
List<QRecord> remaining = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(), input.getTransaction()));
remaining.removeIf(r -> records.stream().anyMatch(d -> Objects.equals(d.getValue("id"), r.getValue("id"))));
if(remaining.stream().anyMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")))
&& remaining.stream().noneMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")) && Boolean.TRUE.equals(r.getValueBoolean("isDefault"))))
{
records.forEach(r -> r.addError(new BadInputStatusMessage("Choose another enabled default before deleting this provider")));
}
for(QRecord row : records)
{
if(!preview && row.getErrors().isEmpty())
{
SystemWrites.delete(EmailTables.SENDER, EmailStore.equal("providerId", row.getValueInteger("id")), input.getTransaction());
}
}
return (records);
}
/*******************************************************************************
** Presence only; internal queries never resolve credentials.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
for(QRecord row : records)
{
String label = row.getValueString("label");
row.setRecordLabel(label == null || label.isBlank() ? row.getValueString("name") : label);
}
if(!SystemWrites.active())
{
for(QRecord row : records)
{
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
{
String value = null;
String name = row.getValueString(field);
EmailQBitConfig config = EmailRuntime.config();
if(name != null && EmailQBitConfig.ENV_NAME.matcher(name).matches() && config.effectiveAllowedEnvNames(row.getValueString("kind")).contains(name))
{
try
{
value = config.getSecretResolver().apply(name);
}
catch(RuntimeException e)
{
// A failing resolver must never leak exception text or values.
}
}
row.setValue(field + "Status", value == null || value.isBlank() ? "Not set" : "Set");
}
}
}
return (records);
}
}
/*******************************************************************************
** Sender addresses: canonical lowercase address, the provider's sending domain,
** unique per provider and at most one enabled default per provider.
*******************************************************************************/
public static class SenderAddressCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Validate each row against its provider and the resulting set of rows.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
if(SystemWrites.active())
{
return (records);
}
QBackendTransaction transaction = input instanceof InsertInput insert ? insert.getTransaction() : input instanceof UpdateInput update ? update.getTransaction() : null;
List<QRecord> resulting = new ArrayList<>();
Set<Serializable> providerIds = new LinkedHashSet<>();
for(QRecord record : records)
{
QRecord merged = effective(record, oldRecords);
if(record.getValueString("address") != null)
{
try
{
String text = record.getValueString("address");
if(text.contains("\r") || text.contains("\n"))
{
throw (new IllegalArgumentException("Sender address must not contain line breaks"));
}
InternetAddress address = new InternetAddress(text, true);
address.validate();
record.setValue("address", address.getAddress().toLowerCase(Locale.ROOT));
merged.setValue("address", record.getValue("address"));
}
catch(Exception e)
{
record.addError(new BadInputStatusMessage("A valid sender address is required"));
}
}
String name = merged.getValueString("displayName");
if(name != null && (name.contains("\r") || name.contains("\n")))
{
record.addError(new BadInputStatusMessage("Display name must not contain line breaks"));
}
QRecord provider = merged.getValue("providerId") == null ? null : EmailStore.get(EmailTables.PROVIDER, merged.getValueInteger("providerId"), transaction);
if(provider == null)
{
record.addError(new BadInputStatusMessage("An existing provider is required"));
}
else if(merged.getValueString("address") != null && record.getErrors().isEmpty())
{
String domain = provider.getValueString("sendingDomain");
String address = merged.getValueString("address");
if(domain != null && !domain.isBlank() && !domain.equalsIgnoreCase(address.substring(address.lastIndexOf('@') + 1)))
{
record.addError(new BadInputStatusMessage("Sender address domain must match the provider sending domain"));
}
}
if(Boolean.TRUE.equals(merged.getValueBoolean("isDefault")) && !Boolean.TRUE.equals(merged.getValueBoolean("enabled")))
{
record.addError(new BadInputStatusMessage("A default sender address must be enabled"));
}
if(merged.getValue("providerId") != null)
{
providerIds.add(merged.getValue("providerId"));
}
resulting.add(merged);
}
if(!providerIds.isEmpty())
{
List<QRecord> existing = SystemWrites.query(EmailTables.SENDER, new QQueryFilter(new QFilterCriteria("providerId", QCriteriaOperator.IN, new ArrayList<>(providerIds))), transaction);
existing.removeIf(r -> records.stream().anyMatch(n -> Objects.equals(n.getValue("id"), r.getValue("id"))));
for(int i = 0; i < records.size(); i++)
{
QRecord merged = resulting.get(i);
List<QRecord> peers = new ArrayList<>(existing);
for(int j = 0; j < resulting.size(); j++)
{
if(j != i)
{
peers.add(resulting.get(j));
}
}
peers.removeIf(r -> !Objects.equals(r.getValue("providerId"), merged.getValue("providerId")));
if(peers.stream().anyMatch(r -> Objects.equals(r.getValueString("address"), merged.getValueString("address"))))
{
records.get(i).addError(new BadInputStatusMessage("That sender address already exists for this provider"));
}
if(Boolean.TRUE.equals(merged.getValueBoolean("isDefault")) && peers.stream().anyMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("isDefault"))))
{
records.get(i).addError(new BadInputStatusMessage("Only one sender address per provider can be the default"));
}
}
}
return (records);
}
}
/*******************************************************************************
** Canonical addresses and administrator protection for complaint removal.
*******************************************************************************/
public static class SuppressionCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Normalize and protect existing complaint rows from being weakened.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
{
for(QRecord record : records)
{
QRecord merged = effective(record, oldRecords);
if(merged.getValue("createdDate") == null)
{
record.setValue("createdDate", EmailRuntime.clock().instant());
}
if(record.getValueString("address") != null)
{
try
{
InternetAddress address = new InternetAddress(record.getValueString("address"), true);
address.validate();
record.setValue("address", address.getAddress().toLowerCase(Locale.ROOT));
}
catch(Exception e)
{
record.addError(new BadInputStatusMessage("A valid suppression address is required"));
}
}
if(oldRecords != null && oldRecords.isPresent() && oldRecords.get().stream().anyMatch(r -> Objects.equals(r.getValue("id"), merged.getValue("id")) && "COMPLAINT".equals(r.getValueString("reason"))))
{
protect(record);
}
}
return (records);
}
/*******************************************************************************
** Only administrators remove complaint suppressions.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview)
{
records.stream().filter(r -> "COMPLAINT".equals(r.getValueString("reason"))).forEach(SuppressionCustomizer::protect);
return (records);
}
/*******************************************************************************
** Apply the complaint permission even if core enforcement is disabled.
*******************************************************************************/
private static void protect(QRecord record)
{
if(!SystemWrites.active() && !EmailPermissionChecker.has(EmailPermissions.ADMIN))
{
record.addError(new BadInputStatusMessage("Changing a complaint suppression needs email.admin"));
}
}
}
/*******************************************************************************
** Link application records through the standard dynamic record-link adornment.
*******************************************************************************/
public static class MessageLinksCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Supply only the target table name; no linked record content is loaded.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
if(!SystemWrites.active())
{
for(QRecord row : records)
{
for(String field : List.of("subject", "htmlBody", "textBody"))
{
String value = row.getValueString(field);
if(value != null && value.startsWith("enc:v1:"))
{
row.setValue(field, "[encrypted]");
row.setDisplayValue(field, "[encrypted]");
}
}
}
}
for(QRecord row : records)
{
String subject = row.getValueString("subject");
boolean secure = Boolean.TRUE.equals(row.getValueBoolean("phiApproved")) || "[encrypted]".equals(subject) || (subject != null && subject.startsWith("enc:v1:"));
row.setRecordLabel(secure ? "Secure message" : subject == null || subject.isBlank() ? "Message " + row.getValue("id") : subject);
}
if(input.getShouldGenerateDisplayValues())
{
for(QRecord row : records)
{
String table = row.getValueString("linkedTableName");
if(table != null && row.getValue("linkedRecordId") != null
&& com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable(table) != null)
{
row.setDisplayValue("linkedRecordId:toRecordFromTableDynamic", table);
}
}
}
return (records);
}
}
}
@@ -1,521 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Standard visible tables, with system-owned messages and attempt claims.
*******************************************************************************/
public final class EmailTables
{
public static final String PROVIDER = "email_provider";
public static final String SENDER = "email_provider_sender_address";
public static final String MESSAGE = "email_message";
public static final String ATTEMPT = "email_message_attempt";
public static final String TEMPLATE = "email_template";
public static final String VERSION = "email_template_version";
public static final String BRAND = "email_brand";
public static final String SUPPRESSION = "email_suppression";
public static final String INBOX = "email_webhook_inbox";
public static final String EVENT = "email_event";
public static final List<String> ALL = List.of(PROVIDER, SENDER, MESSAGE, ATTEMPT, TEMPLATE, VERSION, BRAND, SUPPRESSION, INBOX, EVENT);
public static final List<String> MAIN = List.of(MESSAGE, TEMPLATE, BRAND, SUPPRESSION, PROVIDER);
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT, INBOX, EVENT);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTables()
{
}
/*******************************************************************************
** Shared permission base and custom action checker.
*******************************************************************************/
public static QPermissionRules permissionRules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("email")
.withCustomPermissionChecker(new QCodeReference(EmailPermissionChecker.class)));
}
/*******************************************************************************
** All enum and table possible-value sources.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
List<QPossibleValueSource> result = new ArrayList<>();
result.add(QPossibleValueSource.newForEnum("email_provider_kind", EmailEnums.ProviderKind.values()));
result.add(QPossibleValueSource.newForEnum("email_security", EmailEnums.Security.values()));
result.add(QPossibleValueSource.newForEnum("email_health_status", EmailEnums.HealthStatus.values()));
result.add(QPossibleValueSource.newForEnum("email_message_status", EmailEnums.MessageStatus.values()));
result.add(QPossibleValueSource.newForEnum("email_attempt_outcome", EmailEnums.AttemptOutcome.values()));
result.add(QPossibleValueSource.newForEnum("email_template_kind", EmailEnums.TemplateKind.values()));
result.add(QPossibleValueSource.newForEnum("email_version_status", EmailEnums.VersionStatus.values()));
result.add(QPossibleValueSource.newForEnum("email_suppression_scope", EmailEnums.SuppressionScope.values()));
result.add(QPossibleValueSource.newForEnum("email_suppression_reason", EmailEnums.SuppressionReason.values()));
result.add(QPossibleValueSource.newForEnum("email_category", EmailCategory.values()));
result.add(QPossibleValueSource.newForEnum("email_inbox_status", EmailEnums.InboxStatus.values()));
result.add(QPossibleValueSource.newForEnum("email_event_type", EmailEnums.EventType.values()));
result.add(QPossibleValueSource.newForEnum("email_bounce_type", EmailEnums.BounceType.values()));
ALL.forEach(name -> result.add(QPossibleValueSource.newForTable(name)));
for(QPossibleValueSource source : result)
{
if(List.of(PROVIDER, SENDER, MESSAGE, VERSION).contains(source.getName()))
{
source.withSearchFields(PROVIDER.equals(source.getName()) ? List.of("label", "name") : SENDER.equals(source.getName()) ? List.of("address", "displayName")
: MESSAGE.equals(source.getName()) ? List.of("subject") : List.of("versionNumber", "locale"))
.withOrderByField("id");
}
}
return (result);
}
/*******************************************************************************
** Outbound delivery and tracking tables.
*******************************************************************************/
public static List<QTableMetaData> tables(EmailQBitConfig config)
{
List<QTableMetaData> tables = new ArrayList<>();
tables.add(provider(config));
tables.add(sender(config));
tables.add(message(config));
tables.add(messageattempt(config));
tables.add(template(config));
tables.add(templateversion(config));
tables.add(brand(config));
tables.add(suppression(config));
tables.add(inbox(config));
tables.add(event(config));
for(QTableMetaData table : tables)
{
for(QFieldMetaData field : config.getSecurityFields())
{
table.addField(field.clone());
table.getSections().get(0).getFieldNames().add(field.getName());
}
config.getRecordSecurityLocks().forEach(table::withRecordSecurityLock);
if(SYSTEM_WRITTEN.contains(table.getName()))
{
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
table.getFields().values().forEach(f -> f.setIsEditable(false));
}
table.getFields().values().forEach(f -> f.setBackendName(QInstanceEnricher.inferBackendName(f.getName())));
}
return (tables);
}
/*******************************************************************************
** Common fields and record label.
*******************************************************************************/
private static QTableMetaData base(EmailQBitConfig config, String name, String label)
{
return (new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName())
.withPrimaryKeyField("id").withIcon(new QIcon("mail")).withPermissionRules(permissionRules())
.withRecordLabelFormatAndFields("%s", "id")
.withField(field("id", QFieldType.INTEGER).withIsEditable(false))
.withField(field("createDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
}
/*******************************************************************************
** Field with a plain English label.
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
String words = name.replaceAll("([a-z])([A-Z])", "$1 $2");
return (new QFieldMetaData(name, type).withLabel(Character.toUpperCase(words.charAt(0)) + words.substring(1)));
}
/*******************************************************************************
** Metadata for email_provider.
*******************************************************************************/
private static QTableMetaData provider(EmailQBitConfig config)
{
QTableMetaData table = base(config, PROVIDER, "Provider")
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("label", QFieldType.STRING).withMaxLength(500))
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_provider_kind").withIsRequired(true).withMaxLength(500))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
.withField(field("isDefault", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("fromAddress", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
.withField(field("fromName", QFieldType.STRING).withMaxLength(500))
.withField(field("replyTo", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
.withField(field("sendingDomain", QFieldType.STRING).withMaxLength(500))
.withField(field("host", QFieldType.STRING).withMaxLength(500))
.withField(field("port", QFieldType.INTEGER))
.withField(field("security", QFieldType.STRING).withPossibleValueSourceName("email_security").withDefaultValue("NONE").withMaxLength(500))
.withField(field("region", QFieldType.STRING).withMaxLength(500))
.withField(field("usernameEnvName", QFieldType.STRING).withMaxLength(500))
.withField(field("secretEnvName", QFieldType.STRING).withMaxLength(500))
.withField(field("webhookSecretEnvName", QFieldType.STRING).withMaxLength(500))
.withField(field("snsTopicArn", QFieldType.STRING).withMaxLength(500))
.withField(field("configurationSet", QFieldType.STRING).withMaxLength(500))
.withField(field("messageStream", QFieldType.STRING).withMaxLength(500))
.withField(field("maxPerMinute", QFieldType.INTEGER))
.withField(field("maxPerDay", QFieldType.INTEGER))
.withField(field("trackOpens", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("trackClicks", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("phiAllowed", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("healthStatus", QFieldType.STRING).withPossibleValueSourceName("email_health_status").withDefaultValue("HEALTHY").withMaxLength(500))
.withField(field("lastSuccessDate", QFieldType.DATE_TIME))
.withField(field("lastFailureDate", QFieldType.DATE_TIME))
.withField(field("lastFailureMessage", QFieldType.STRING).withMaxLength(500))
.withField(field("dispatchLock", QFieldType.INTEGER).withDefaultValue(0).withIsHidden(true).withIsEditable(false))
.withField(field("defaultSlot", QFieldType.INTEGER).withIsHidden(true).withIsEditable(false));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.withUniqueKey(new UniqueKey("name"));
table.withUniqueKey(new UniqueKey("defaultSlot"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "kind", "enabled", "isDefault"));
table.withSection(SectionFactory.defaultT2("fromAddress", "fromName", "replyTo", "sendingDomain", "host", "port", "security", "region", "usernameEnvName", "secretEnvName", "webhookSecretEnvName", "snsTopicArn", "configurationSet", "messageStream", "maxPerMinute", "maxPerDay", "trackOpens", "trackClicks", "phiAllowed", "healthStatus", "lastSuccessDate", "lastFailureDate", "lastFailureMessage"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("senders", new QIcon("list")).withLabel("Sender Addresses").withWidgetName(SENDER + "_children"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Messages").withWidgetName(PROVIDER + "_children"));
table.withSection(SectionFactory.customT2("inbox", new QIcon("list")).withLabel("Webhook Inbox").withWidgetName(INBOX + "_children"));
return (table);
}
/*******************************************************************************
** Metadata for email_provider_sender_address. The provider list in the
** nav is the only entry point; rows are managed as a child list on the provider.
*******************************************************************************/
private static QTableMetaData sender(EmailQBitConfig config)
{
QTableMetaData table = base(config, SENDER, "Provider Sender Address")
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER).withIsRequired(true))
.withField(field("address", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("displayName", QFieldType.STRING).withMaxLength(500))
.withField(field("isDefault", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
.withField(field("note", QFieldType.TEXT));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.withUniqueKey(new UniqueKey("providerId", "address"));
table.withSection(SectionFactory.defaultT1("id", "providerId", "address", "displayName", "isDefault", "enabled"));
table.withSection(SectionFactory.defaultT2("note"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for email_message.
*******************************************************************************/
private static QTableMetaData message(EmailQBitConfig config)
{
QTableMetaData table = base(config, MESSAGE, "Message")
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_message_status").withMaxLength(500))
.withField(field("toAddresses", QFieldType.TEXT).withIsSensitive(true))
.withField(field("ccAddresses", QFieldType.TEXT).withIsSensitive(true))
.withField(field("bccAddresses", QFieldType.TEXT).withIsSensitive(true))
.withField(field("fromAddress", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
.withField(field("fromAddressId", QFieldType.INTEGER).withPossibleValueSourceName(SENDER).withPossibleValueSourceFilter(EmailStore.equal("providerId", "${input.providerId}")))
.withField(field("replyTo", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE))
.withField(field("templateVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION).withPossibleValueSourceFilter(EmailStore.equal("templateId", "${input.templateId}")))
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500))
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true))
.withField(field("htmlBody", QFieldType.TEXT).withIsSensitive(true))
.withField(field("textBody", QFieldType.TEXT).withIsSensitive(true))
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
.withField(field("unsubscribeUrl", QFieldType.TEXT))
.withField(field("bodySha256", QFieldType.STRING).withMaxLength(500))
.withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500))
.withField(field("rfcMessageId", QFieldType.STRING).withMaxLength(500))
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
.withField(field("providerMessageId", QFieldType.STRING).withMaxLength(500))
.withField(field("attemptCount", QFieldType.INTEGER).withDefaultValue(0))
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
.withField(field("leaseExpiresDate", QFieldType.DATE_TIME))
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
.withField(field("finishedDate", QFieldType.DATE_TIME))
.withField(field("sentDate", QFieldType.DATE_TIME))
.withField(field("deliveredDate", QFieldType.DATE_TIME))
.withField(field("bouncedDate", QFieldType.DATE_TIME))
.withField(field("complainedDate", QFieldType.DATE_TIME))
.withField(field("deadLetterDate", QFieldType.DATE_TIME))
.withField(field("linkedTableName", QFieldType.STRING).withMaxLength(500))
.withField(field("linkedRecordId", QFieldType.STRING).withMaxLength(500)
.withFieldAdornment(new FieldAdornment(AdornmentType.LINK).withValue(AdornmentType.LinkValues.TO_RECORD_FROM_TABLE_DYNAMIC, true)))
.withField(field("senderQbit", QFieldType.STRING).withMaxLength(500))
.withField(field("senderProcess", QFieldType.STRING).withMaxLength(500))
.withField(field("suppressedAddresses", QFieldType.TEXT).withIsSensitive(true))
.withField(field("sendAfter", QFieldType.DATE_TIME));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.getField("subject").setIsHeavy(false);
table.withUniqueKey(new UniqueKey("idempotencyKey"));
table.withUniqueKey(new UniqueKey("rfcMessageId"));
table.withSection(SectionFactory.defaultT1("id", "status", "toAddresses", "ccAddresses", "bccAddresses", "fromAddress"));
table.withSection(SectionFactory.defaultT2("fromAddressId", "replyTo", "subject", "templateId", "templateVersionId", "brandId", "category", "locale", "variablesSnapshot", "htmlBody", "textBody", "phiApproved", "unsubscribeUrl", "bodySha256", "idempotencyKey", "rfcMessageId", "providerId", "providerMessageId", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastError", "finishedDate", "sentDate", "deliveredDate", "bouncedDate", "complainedDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderQbit", "senderProcess", "suppressedAddresses", "sendAfter"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Attempts").withWidgetName(MESSAGE + "_children"));
table.withSection(SectionFactory.customT2("events", new QIcon("list")).withLabel("Events").withWidgetName(EVENT + "_children"));
return (table);
}
/*******************************************************************************
** Metadata for email_message_attempt.
*******************************************************************************/
private static QTableMetaData messageattempt(EmailQBitConfig config)
{
QTableMetaData table = base(config, ATTEMPT, "Message Attempt")
.withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE).withIsRequired(true).withPossibleValueSourceFilter(EmailStore.equal("providerId", "${input.providerId}")))
.withField(field("attemptNumber", QFieldType.INTEGER).withIsRequired(true))
.withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("email_attempt_outcome").withMaxLength(500))
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
.withField(field("claimedBy", QFieldType.STRING).withMaxLength(500))
.withField(field("startDate", QFieldType.DATE_TIME))
.withField(field("endDate", QFieldType.DATE_TIME))
.withField(field("leaseExpiresDate", QFieldType.DATE_TIME))
.withField(field("durationMillis", QFieldType.INTEGER))
.withField(field("error", QFieldType.STRING).withMaxLength(500));
table.withUniqueKey(new UniqueKey("messageId", "attemptNumber"));
table.withSection(SectionFactory.defaultT1("id", "messageId", "attemptNumber", "outcome", "providerId", "claimedBy"));
table.withSection(SectionFactory.defaultT2("startDate", "endDate", "leaseExpiresDate", "durationMillis", "error"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for email_template.
*******************************************************************************/
private static QTableMetaData template(EmailQBitConfig config)
{
QTableMetaData table = base(config, TEMPLATE, "Template")
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("label", QFieldType.STRING).withMaxLength(500))
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_template_kind").withIsRequired(true).withMaxLength(500))
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
.withField(field("activeVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION).withIsEditable(false).withPossibleValueSourceFilter(EmailStore.equal("templateId", "${input.id}")))
.withField(field("approvedVersionId", QFieldType.INTEGER).withIsEditable(false).withPossibleValueSourceName(VERSION).withPossibleValueSourceFilter(EmailStore.equal("templateId", "${input.id}")))
.withField(field("versionLock", QFieldType.INTEGER).withIsHidden(true).withIsEditable(false))
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
.withField(field("phiApprovedBy", QFieldType.STRING).withMaxLength(500).withIsEditable(false))
.withField(field("phiApprovedDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("editPermissionName", QFieldType.STRING).withMaxLength(500))
.withField(field("ownerQbit", QFieldType.STRING).withMaxLength(500))
.withField(field("description", QFieldType.TEXT));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "category", "kind", "brandId"));
table.withSection(SectionFactory.defaultT2("activeVersionId", "approvedVersionId", "phiApproved", "phiApprovedBy", "phiApprovedDate", "editPermissionName", "ownerQbit", "description"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Versions").withWidgetName(TEMPLATE + "_children"));
return (table);
}
/*******************************************************************************
** Metadata for email_template_version.
*******************************************************************************/
private static QTableMetaData templateversion(EmailQBitConfig config)
{
QTableMetaData table = base(config, VERSION, "Template Version")
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE).withIsRequired(true))
.withField(field("versionNumber", QFieldType.INTEGER).withIsRequired(true))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_version_status").withDefaultValue("DRAFT").withIsEditable(false).withMaxLength(500))
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500).withIsRequired(true))
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
.withField(field("preheader", QFieldType.TEXT))
.withField(field("bodySource", QFieldType.TEXT))
.withField(field("textSource", QFieldType.TEXT))
.withField(field("compiledHtml", QFieldType.TEXT).withIsEditable(false))
.withField(field("variablesSchema", QFieldType.TEXT))
.withField(field("sampleVariables", QFieldType.TEXT))
.withField(field("createdBy", QFieldType.STRING).withMaxLength(500))
.withField(field("activatedDate", QFieldType.DATE_TIME));
table.withRecordLabelFormat(null).withRecordLabelFields(List.of());
table.withUniqueKey(new UniqueKey("templateId", "versionNumber", "locale"));
table.withSection(SectionFactory.customT2("preview", new QIcon("preview")).withLabel("Preview").withWidgetName("emailTemplatePreview"));
table.withSection(SectionFactory.defaultT1("id", "templateId", "versionNumber", "status", "locale", "subject"));
table.withSection(SectionFactory.defaultT2("preheader", "bodySource", "textSource", "compiledHtml", "variablesSchema", "sampleVariables", "createdBy", "activatedDate"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for email_brand.
*******************************************************************************/
private static QTableMetaData brand(EmailQBitConfig config)
{
QTableMetaData table = base(config, BRAND, "Brand")
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("label", QFieldType.STRING).withMaxLength(500))
.withField(field("logoUrl", QFieldType.STRING).withMaxLength(500))
.withField(field("logoDarkUrl", QFieldType.STRING).withMaxLength(500))
.withField(field("logoAlt", QFieldType.STRING).withMaxLength(500))
.withField(field("primaryColor", QFieldType.STRING).withMaxLength(500))
.withField(field("textColor", QFieldType.STRING).withMaxLength(500))
.withField(field("backgroundColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkBackgroundColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkPrimaryColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkButtonTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("darkTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("buttonTextColor", QFieldType.STRING).withMaxLength(500))
.withField(field("footerMarkdown", QFieldType.TEXT))
.withField(field("postalAddress", QFieldType.TEXT))
.withField(field("socialLinks", QFieldType.TEXT))
.withField(field("siteUrl", QFieldType.STRING).withMaxLength(500))
.withField(field("isDefault", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("appName", QFieldType.STRING).withMaxLength(500));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "logoUrl", "logoDarkUrl", "logoAlt"));
table.withSection(SectionFactory.defaultT2("primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "darkPrimaryColor", "darkButtonTextColor", "buttonTextColor", "footerMarkdown", "postalAddress", "socialLinks", "siteUrl", "isDefault", "appName"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for email_suppression.
*******************************************************************************/
private static QTableMetaData suppression(EmailQBitConfig config)
{
QTableMetaData table = base(config, SUPPRESSION, "Suppression")
.withField(field("address", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("scope", QFieldType.STRING).withPossibleValueSourceName("email_suppression_scope").withIsRequired(true).withMaxLength(500))
.withField(field("reason", QFieldType.STRING).withPossibleValueSourceName("email_suppression_reason").withIsRequired(true).withMaxLength(500))
.withField(field("sourceMessageId", QFieldType.INTEGER))
.withField(field("sourceEventId", QFieldType.INTEGER).withPossibleValueSourceName(EVENT).withPossibleValueSourceFilter(EmailStore.equal("messageId", "${input.sourceMessageId}")))
.withField(field("note", QFieldType.TEXT))
.withField(field("createdDate", QFieldType.DATE_TIME))
.withField(field("expiresDate", QFieldType.DATE_TIME));
table.withUniqueKey(new UniqueKey("address", "scope"));
table.withSection(SectionFactory.defaultT1("id", "address", "scope", "reason", "sourceMessageId", "sourceEventId"));
table.withSection(SectionFactory.defaultT2("note", "createdDate", "expiresDate"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** System-written webhook inbox records.
*******************************************************************************/
private static QTableMetaData inbox(EmailQBitConfig config)
{
QTableMetaData table = base(config, INBOX, "Webhook Inbox")
.withField(field("providerId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(PROVIDER))
.withField(field("kind", QFieldType.STRING).withMaxLength(500))
.withField(field("externalEventId", QFieldType.STRING).withMaxLength(500).withIsRequired(true))
.withField(field("nonce", QFieldType.STRING).withMaxLength(500))
.withField(field("status", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withPossibleValueSourceName("email_inbox_status"))
.withField(field("attempts", QFieldType.INTEGER))
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
.withField(field("payloadJson", QFieldType.TEXT).withIsSensitive(true))
.withField(field("payloadSha256", QFieldType.STRING).withMaxLength(500))
.withField(field("receivedDate", QFieldType.DATE_TIME))
.withField(field("processedDate", QFieldType.DATE_TIME));
table.withUniqueKey(new UniqueKey("providerId", "externalEventId"));
table.withUniqueKey(new UniqueKey("providerId", "nonce"));
table.withSection(SectionFactory.defaultT1("id", "providerId", "kind", "externalEventId", "nonce", "status", "attempts", "nextAttemptDate", "lastError", "payloadJson", "payloadSha256", "receivedDate", "processedDate"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** System-written event records.
*******************************************************************************/
private static QTableMetaData event(EmailQBitConfig config)
{
QTableMetaData table = base(config, EVENT, "Event")
.withField(field("messageId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(MESSAGE).withPossibleValueSourceFilter(EmailStore.equal("providerId", "${input.providerId}")))
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
.withField(field("eventType", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withPossibleValueSourceName("email_event_type"))
.withField(field("bounceType", QFieldType.STRING).withMaxLength(500).withPossibleValueSourceName("email_bounce_type"))
.withField(field("recipient", QFieldType.STRING).withMaxLength(500).withIsSensitive(true))
.withField(field("occurredDate", QFieldType.DATE_TIME))
.withField(field("detail", QFieldType.STRING).withMaxLength(500))
.withField(field("url", QFieldType.TEXT).withIsSensitive(true))
.withField(field("eventKey", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withIsHidden(true));
table.withUniqueKey(new UniqueKey("eventKey"));
table.withSection(SectionFactory.defaultT1("id", "messageId", "providerId", "eventType", "bounceType", "recipient", "occurredDate", "detail", "url"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Relations used by child lists.
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
return (List.of(join(PROVIDER, MESSAGE, "providerId"), join(MESSAGE, ATTEMPT, "messageId"), join(TEMPLATE, VERSION, "templateId"), join(PROVIDER, INBOX, "providerId"), join(PROVIDER, SENDER, "providerId"), join(MESSAGE, EVENT, "messageId")));
}
/*******************************************************************************
** Parent to child relation.
*******************************************************************************/
private static QJoinMetaData join(String parent, String child, String field)
{
return (new QJoinMetaData().withName(parent + "_join_" + child).withType(JoinType.ONE_TO_MANY)
.withLeftTable(parent).withRightTable(child).withJoinOn(new JoinOn("id", field)));
}
/*******************************************************************************
** Child lists live on the parent record, never in navigation.
*******************************************************************************/
public static List<QWidgetMetaData> childWidgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
for(QJoinMetaData join : joins())
{
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(List.of(INBOX, EVENT, SENDER).contains(join.getRightTable()) ? join.getRightTable() + "_children" : join.getLeftTable() + "_children")
.withLabel(join.getRightTable().equals(INBOX) ? "Webhook Inbox" : join.getRightTable().equals(SENDER) ? "Sender Addresses" : join.getRightTable().equals(EVENT) ? "Events" : join.getRightTable().equals(ATTEMPT) ? "Attempts" : join.getRightTable().equals(VERSION) ? "Versions" : "Messages")
.withMaxRows(50).getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
}
return (widgets);
}
}
@@ -1,55 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Branded ad-hoc rendering. Markdown raw HTML and unsafe URL schemes are disabled.
*******************************************************************************/
public class AdHocEmailRenderer implements EmailRenderer
{
/*******************************************************************************
** Render markdown, HTML, or plain text into both alternatives.
*******************************************************************************/
@Override
public Rendered render(EmailRequest request)
{
try
{
var brand = com.kingsrook.qbits.email.render.EmailBrands.resolve(request.getBrandName(), null);
String html = request.getHtmlBody();
if(html == null)
{
String body = request.getMarkdownBody() == null ? "<pre>" + com.kingsrook.qbits.email.template.EmailTemplateEngine.escape(request.getTextBody()) + "</pre>"
: com.kingsrook.qbits.email.render.EmailMarkdown.render(request.getMarkdownBody(), brand);
html = com.kingsrook.qbits.email.render.EmailLayout.render(body, "", brand,
request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory(), "");
}
return (new Rendered(html, request.getTextBody() == null ? com.kingsrook.qbits.email.render.HtmlToText.convert(html) : request.getTextBody()));
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IllegalArgumentException("Email brand is unavailable"));
}
}
}
@@ -1,54 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
/*******************************************************************************
** Purpose of a message; transactional and security messages cannot be opted out.
*******************************************************************************/
public enum EmailCategory implements PossibleValueEnum<String>
{
TRANSACTIONAL,
NOTIFICATION,
MARKETING,
SECURITY;
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Display label.
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT));
}
}
@@ -1,33 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Internal; use EmailApi.
** Durable handoff outcomes. Operational failures propagate to the caller.
*******************************************************************************/
@Deprecated(forRemoval = false)
public enum EmailEffectOutcome
{
QUEUED_OR_DUPLICATE,
REFUSED
}
@@ -1,116 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
import java.util.List;
import java.util.Optional;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Internal; use EmailApi.
** Outbox handoff and system-read reconciliation for trusted effect dispatchers.
** This API has no dependency on core durable-job types. Sending retains the
** EmailService permission checks; callers must supply a request without a transaction.
*******************************************************************************/
@Deprecated(forRemoval = false)
public final class EmailEffects
{
/*******************************************************************************
** A refusal can have a reference when suppression retained an audit row.
*******************************************************************************/
public record Result(EmailEffectOutcome outcome, EmailMessageRef message)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailEffects()
{
}
/*******************************************************************************
** Hand off once with the effect's identity. Refuse invalid or conflicting keys
** and caller transactions: success must mean the outbox has committed already.
** Database failures throw QException and must not become a permanent refusal.
*******************************************************************************/
public static Result send(EmailRequest request, String effectIdempotencyKey) throws QException
{
if(request == null || effectIdempotencyKey == null || effectIdempotencyKey.isBlank() || effectIdempotencyKey.length() > 500
|| request.getTransaction() != null
|| request.getIdempotencyKey() != null && !request.getIdempotencyKey().equals(effectIdempotencyKey))
{
return (new Result(EmailEffectOutcome.REFUSED, null));
}
request.withIdempotencyKey(effectIdempotencyKey);
EmailSendResult result = EmailService.send(request);
EmailMessageRef message = result.messageId() == null ? null : find("id", result.messageId()).orElseThrow(() -> new QException("Queued email reference is unavailable"));
boolean refused = result.status() == EmailSendResult.Status.REFUSED || result.status() == EmailSendResult.Status.SUPPRESSED
|| message != null && "SUPPRESSED".equals(message.status());
return (new Result(refused ? EmailEffectOutcome.REFUSED : EmailEffectOutcome.QUEUED_OR_DUPLICATE, message));
}
/*******************************************************************************
** Look up a committed outbox row by its exact effect or application key.
*******************************************************************************/
public static Optional<EmailMessageRef> findByIdempotencyKey(String key) throws QException
{
return (key == null || key.isBlank() ? Optional.empty() : find("idempotencyKey", key));
}
/*******************************************************************************
** Look up the exact bracketed RFC Message-ID stored at enqueue time.
*******************************************************************************/
public static Optional<EmailMessageRef> findByRfcMessageId(String messageId) throws QException
{
return (messageId == null || messageId.isBlank() ? Optional.empty() : find("rfcMessageId", messageId));
}
/*******************************************************************************
** Use the same authorized read scope as the dispatcher; expose only references.
*******************************************************************************/
private static Optional<EmailMessageRef> find(String field, java.io.Serializable value) throws QException
{
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal(field, value).withLimit(1));
if(rows.isEmpty())
{
return (Optional.empty());
}
QRecord row = rows.getFirst();
return (Optional.of(new EmailMessageRef(row.getValueInteger("id"), row.getValueString("status"), row.getValueString("rfcMessageId"), row.getValueString("providerMessageId"))));
}
}
@@ -1,31 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Internal; use EmailApi.
** Reconciliation reference without message bodies, recipients or credentials.
*******************************************************************************/
@Deprecated(forRemoval = false)
public record EmailMessageRef(Integer id, String status, String rfcMessageId, String providerMessageId)
{
}
@@ -1,43 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
/*******************************************************************************
** Replaceable rendering seam for the versioned template and brand renderer.
*******************************************************************************/
@FunctionalInterface
public interface EmailRenderer
{
/*******************************************************************************
** Render already validated ad-hoc content.
*******************************************************************************/
Rendered render(EmailRequest request);
/*******************************************************************************
** Rendered alternatives persisted once for all attempts.
*******************************************************************************/
record Rendered(String html, String text)
{
}
}
@@ -1,630 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
/*******************************************************************************
** Synchronous enqueue request with explicit record provenance.
*******************************************************************************/
public class EmailRequest
{
private List<String> to = new ArrayList<>();
private List<String> cc = new ArrayList<>();
private List<String> bcc = new ArrayList<>();
private String from;
private Integer fromAddressId;
private String replyTo;
private String subject;
private String markdownBody;
private String htmlBody;
private String textBody;
private String providerName;
private String templateName;
private String brandName;
private String locale = "en";
private EmailCategory category;
private String idempotencyKey;
private Instant sendAfter;
private String linkedTableName;
private String linkedRecordId;
private String senderQbit;
private String senderProcess;
private QBackendTransaction transaction;
private String transactionBackendName;
private Map<String, RecordVariable> recordVariables = new LinkedHashMap<>();
private Map<String, Serializable> variables = new LinkedHashMap<>();
private Map<String, Serializable> securityValues = new LinkedHashMap<>();
/*******************************************************************************
** Append to recipients.
*******************************************************************************/
public EmailRequest withTo(String... addresses)
{
to.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append cc recipients.
*******************************************************************************/
public EmailRequest withCc(String... addresses)
{
cc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Append bcc recipients.
*******************************************************************************/
public EmailRequest withBcc(String... addresses)
{
bcc.addAll(List.of(addresses));
return (this);
}
/*******************************************************************************
** Link the message to an application record.
*******************************************************************************/
public EmailRequest withLinkedRecord(String table, Object id)
{
linkedTableName = table;
linkedRecordId = id == null ? null : String.valueOf(id);
return (this);
}
/*******************************************************************************
** Identify the producing qbit and process.
*******************************************************************************/
public EmailRequest withSender(String qbit, String process)
{
senderQbit = qbit;
senderProcess = process;
return (this);
}
/*******************************************************************************
** Declare the backend of the caller transaction; QBackendTransaction does not expose it.
*******************************************************************************/
public EmailRequest withTransaction(QBackendTransaction transaction, String backendName)
{
this.transaction = transaction;
this.transactionBackendName = backendName;
return (this);
}
/*******************************************************************************
** Bind one schema variable.
*******************************************************************************/
public EmailRequest withVariable(String name, Serializable value)
{
variables.put(name, value);
return (this);
}
/*******************************************************************************
** Accept a record reference without reading or snapshotting the record.
*******************************************************************************/
public EmailRequest withRecordVariable(String name, String table, Serializable id)
{
recordVariables.put(name, new RecordVariable(table, id));
return (this);
}
/*******************************************************************************
** Getter for to.
*******************************************************************************/
public List<String> getTo()
{
return (to);
}
/*******************************************************************************
** Fluent setter for to.
*******************************************************************************/
public EmailRequest withTo(List<String> to)
{
this.to = to;
return (this);
}
/*******************************************************************************
** Getter for cc.
*******************************************************************************/
public List<String> getCc()
{
return (cc);
}
/*******************************************************************************
** Fluent setter for cc.
*******************************************************************************/
public EmailRequest withCc(List<String> cc)
{
this.cc = cc;
return (this);
}
/*******************************************************************************
** Getter for bcc.
*******************************************************************************/
public List<String> getBcc()
{
return (bcc);
}
/*******************************************************************************
** Fluent setter for bcc.
*******************************************************************************/
public EmailRequest withBcc(List<String> bcc)
{
this.bcc = bcc;
return (this);
}
/*******************************************************************************
** Getter for from.
*******************************************************************************/
public String getFrom()
{
return (from);
}
/*******************************************************************************
** Fluent setter for from.
*******************************************************************************/
public EmailRequest withFrom(String from)
{
this.from = from;
return (this);
}
/*******************************************************************************
** Getter for fromAddressId.
*******************************************************************************/
public Integer getFromAddressId()
{
return (fromAddressId);
}
/*******************************************************************************
** Fluent setter for fromAddressId, a row of email_provider_sender_address that
** must belong to the resolved provider and be enabled.
*******************************************************************************/
public EmailRequest withFromAddressId(Integer fromAddressId)
{
this.fromAddressId = fromAddressId;
return (this);
}
/*******************************************************************************
** Getter for replyTo.
*******************************************************************************/
public String getReplyTo()
{
return (replyTo);
}
/*******************************************************************************
** Fluent setter for replyTo.
*******************************************************************************/
public EmailRequest withReplyTo(String replyTo)
{
this.replyTo = replyTo;
return (this);
}
/*******************************************************************************
** Getter for subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Fluent setter for subject.
*******************************************************************************/
public EmailRequest withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Getter for markdownBody.
*******************************************************************************/
public String getMarkdownBody()
{
return (markdownBody);
}
/*******************************************************************************
** Fluent setter for markdownBody.
*******************************************************************************/
public EmailRequest withMarkdownBody(String markdownBody)
{
this.markdownBody = markdownBody;
return (this);
}
/*******************************************************************************
** Getter for htmlBody.
*******************************************************************************/
public String getHtmlBody()
{
return (htmlBody);
}
/*******************************************************************************
** Fluent setter for htmlBody.
*******************************************************************************/
public EmailRequest withHtmlBody(String htmlBody)
{
this.htmlBody = htmlBody;
return (this);
}
/*******************************************************************************
** Getter for textBody.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** Fluent setter for textBody.
*******************************************************************************/
public EmailRequest withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** Getter for providerName.
*******************************************************************************/
public String getProviderName()
{
return (providerName);
}
/*******************************************************************************
** Fluent setter for providerName.
*******************************************************************************/
public EmailRequest withProviderName(String providerName)
{
this.providerName = providerName;
return (this);
}
/*******************************************************************************
** Getter for templateName.
*******************************************************************************/
public String getTemplateName()
{
return (templateName);
}
/*******************************************************************************
** Fluent setter for templateName.
*******************************************************************************/
public EmailRequest withTemplateName(String templateName)
{
this.templateName = templateName;
return (this);
}
/*******************************************************************************
** Getter for brandName.
*******************************************************************************/
public String getBrandName()
{
return (brandName);
}
/*******************************************************************************
** Fluent setter for brandName.
*******************************************************************************/
public EmailRequest withBrandName(String brandName)
{
this.brandName = brandName;
return (this);
}
/*******************************************************************************
** Getter for locale.
*******************************************************************************/
public String getLocale()
{
return (locale);
}
/*******************************************************************************
** Fluent setter for locale.
*******************************************************************************/
public EmailRequest withLocale(String locale)
{
this.locale = locale;
return (this);
}
/*******************************************************************************
** Getter for category.
*******************************************************************************/
public EmailCategory getCategory()
{
return (category);
}
/*******************************************************************************
** Fluent setter for category.
*******************************************************************************/
public EmailRequest withCategory(EmailCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Fluent setter for idempotencyKey.
*******************************************************************************/
public EmailRequest withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Getter for sendAfter.
*******************************************************************************/
public Instant getSendAfter()
{
return (sendAfter);
}
/*******************************************************************************
** Fluent setter for sendAfter.
*******************************************************************************/
public EmailRequest withSendAfter(Instant sendAfter)
{
this.sendAfter = sendAfter;
return (this);
}
/*******************************************************************************
** Getter for linkedTableName.
*******************************************************************************/
public String getLinkedTableName()
{
return (linkedTableName);
}
/*******************************************************************************
** Fluent setter for linkedTableName.
*******************************************************************************/
public EmailRequest withLinkedTableName(String linkedTableName)
{
this.linkedTableName = linkedTableName;
return (this);
}
/*******************************************************************************
** Getter for linkedRecordId.
*******************************************************************************/
public String getLinkedRecordId()
{
return (linkedRecordId);
}
/*******************************************************************************
** Fluent setter for linkedRecordId.
*******************************************************************************/
public EmailRequest withLinkedRecordId(String linkedRecordId)
{
this.linkedRecordId = linkedRecordId;
return (this);
}
/*******************************************************************************
** Getter for senderQbit.
*******************************************************************************/
public String getSenderQbit()
{
return (senderQbit);
}
/*******************************************************************************
** Fluent setter for senderQbit.
*******************************************************************************/
public EmailRequest withSenderQbit(String senderQbit)
{
this.senderQbit = senderQbit;
return (this);
}
/*******************************************************************************
** Getter for senderProcess.
*******************************************************************************/
public String getSenderProcess()
{
return (senderProcess);
}
/*******************************************************************************
** Fluent setter for senderProcess.
*******************************************************************************/
public EmailRequest withSenderProcess(String senderProcess)
{
this.senderProcess = senderProcess;
return (this);
}
/*******************************************************************************
** Getter for transaction.
*******************************************************************************/
public QBackendTransaction getTransaction()
{
return (transaction);
}
/*******************************************************************************
** Fluent setter for transaction.
*******************************************************************************/
public EmailRequest withTransaction(QBackendTransaction transaction)
{
this.transaction = transaction;
return (this);
}
/*******************************************************************************
** Getter for transactionBackendName.
*******************************************************************************/
public String getTransactionBackendName()
{
return (transactionBackendName);
}
/*******************************************************************************
** Fluent setter for transactionBackendName.
*******************************************************************************/
public EmailRequest withTransactionBackendName(String transactionBackendName)
{
this.transactionBackendName = transactionBackendName;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public Map<String, Serializable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public EmailRequest withVariables(Map<String, Serializable> variables)
{
this.variables = variables;
return (this);
}
/*******************************************************************************
** Getter for securityValues.
*******************************************************************************/
public Map<String, Serializable> getSecurityValues()
{
return (securityValues);
}
/*******************************************************************************
** Fluent setter for securityValues.
*******************************************************************************/
public EmailRequest withSecurityValues(Map<String, Serializable> securityValues)
{
this.securityValues = securityValues;
return (this);
}
/*******************************************************************************
** A record reference is distinct from an ordinary JSON object.
*******************************************************************************/
public record RecordVariable(String table, Serializable id)
{
}
/*******************************************************************************
** Record bindings, read under the sender session by the service.
*******************************************************************************/
public Map<String, RecordVariable> getRecordVariables()
{
return (recordVariables);
}
}
@@ -1,42 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
import java.util.List;
/*******************************************************************************
** Queue result. Cross-backend deferred inserts initially have no message id.
*******************************************************************************/
public record EmailSendResult(Integer messageId, Status status, List<String> reasons)
{
/*******************************************************************************
** Enqueue outcomes.
*******************************************************************************/
public enum Status
{
QUEUED,
DUPLICATE,
SUPPRESSED,
REFUSED
}
}
@@ -1,636 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.outbox;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Validated transactional outbox API. This class never performs network delivery.
*******************************************************************************/
public final class EmailService
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailService()
{
}
/*******************************************************************************
** Queue validated, rendered alternatives with optional versioned templates.
*******************************************************************************/
public static EmailSendResult send(EmailRequest request) throws QException
{
return (send(request, new AdHocEmailRenderer()));
}
/*******************************************************************************
** Queue with a host-supplied ad-hoc renderer; templates always use the validated engine.
*******************************************************************************/
public static EmailSendResult send(EmailRequest request, EmailRenderer renderer) throws QException
{
return (send(request, renderer, null));
}
/*******************************************************************************
** Shared enqueue path, including already protected retained content.
*******************************************************************************/
private static EmailSendResult send(EmailRequest request, EmailRenderer renderer, QRecord retained) throws QException
{
EmailQBitConfig config = EmailRuntime.config();
if(config == null)
{
return (refused("Email qbit is not installed"));
}
if(CorePermissionEnforcer.shouldEnforce(new SendMessageInput()) && !EmailPermissionChecker.has(EmailPermissions.SEND))
{
return (refused("Sending email needs email.send"));
}
if(request == null)
{
return (refused("An email request is required"));
}
EmailTemplateRenderer templateRenderer;
QRecord templateContent;
try
{
templateRenderer = request.getTemplateName() == null ? null : EmailTemplateRenderer.prepare(request);
templateContent = templateRenderer == null ? retained : templateRenderer.render("pending", "https://example.invalid/unsubscribe/pending", true);
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
if(templateRenderer == null && (!request.getVariables().isEmpty() || !request.getRecordVariables().isEmpty()))
{
return (refused("Variables require a template with a schema"));
}
if(templateRenderer == null && (request.getSubject() == null || request.getSubject().contains("\r") || request.getSubject().contains("\n")
|| (request.getMarkdownBody() == null && request.getHtmlBody() == null && request.getTextBody() == null)))
{
return (refused("A subject and message body are required; subject must be one line"));
}
EmailCategory category = templateContent == null ? (request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory())
: EmailCategory.valueOf(templateContent.getValueString("category"));
QBackendTransaction transaction = request.getTransaction();
boolean sameBackend = transaction == null || request.getTransactionBackendName() == null || config.getBackendName().equals(request.getTransactionBackendName());
QBackendTransaction queryTransaction = sameBackend ? transaction : null;
QRecord duplicate = duplicate(request.getIdempotencyKey(), queryTransaction);
if(duplicate != null)
{
return (new EmailSendResult(duplicate.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
}
String providerName = request.getProviderName() == null ? config.getDefaultProviderName() : request.getProviderName();
QQueryFilter providerFilter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true));
providerFilter.withCriteria(new QFilterCriteria(providerName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, providerName == null ? true : providerName));
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, providerFilter, queryTransaction);
if(providers.size() != 1)
{
return (refused("An enabled named or default provider is required"));
}
QRecord provider = providers.get(0);
List<String> providerErrors = EmailTableCustomizers.validateProvider(provider, config);
if(!providerErrors.isEmpty())
{
return (new EmailSendResult(null, EmailSendResult.Status.REFUSED, providerErrors));
}
try
{
if(templateRenderer != null)
{
templateRenderer.validateProvider(provider);
}
else if(retained != null && Boolean.TRUE.equals(retained.getValueBoolean("phiApproved")))
{
EmailTemplateRenderer.checkProvider(provider);
com.kingsrook.qbits.email.phi.EmailBodyEncryption.encrypt(null, "subject", "pending");
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
List<String> to;
List<String> cc;
List<String> bcc;
String from;
String replyTo;
QRecord senderRow;
try
{
senderRow = senderRow(provider, SystemWrites.query(EmailTables.SENDER, EmailStore.equal("providerId", provider.getValue("id")), queryTransaction), request);
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
try
{
to = addresses(request.getTo());
cc = addresses(request.getCc());
bcc = addresses(request.getBcc());
String sender = request.getFrom() != null ? request.getFrom() : senderRow != null ? senderRow.getValueString("address") : provider.getValueString("fromAddress");
from = single(sender);
if(request.getFrom() == null)
{
String name = senderRow != null ? senderRow.getValueString("displayName") : provider.getValueString("fromName");
if(name != null && !name.isBlank())
{
from = new InternetAddress(new InternetAddress(from).getAddress(), name, "UTF-8").toString();
}
}
String reply = request.getReplyTo() == null ? provider.getValueString("replyTo") : request.getReplyTo();
replyTo = reply == null ? null : single(reply);
String domain = provider.getValueString("sendingDomain");
String address = new InternetAddress(from).getAddress();
if(domain != null && !domain.equalsIgnoreCase(address.substring(address.lastIndexOf('@') + 1)))
{
return (refused("From address domain must match sending domain"));
}
}
catch(Exception e)
{
return (refused("One or more email addresses are invalid"));
}
try
{
String unsubscribe = EmailUnsubscribeLinks.create("pending", String.join(",", to), category.name());
if(unsubscribe != null || retained != null && retained.getValueString("unsubscribeUrl") != null)
{
EmailUnsubscribeLinks.recipient(String.join(",", to), String.join(",", cc), String.join(",", bcc));
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
if(to.isEmpty() && cc.isEmpty() && bcc.isEmpty())
{
return (refused("At least one recipient is required"));
}
Set<String> blocked = suppressed(category, queryTransaction);
List<String> dropped = new ArrayList<>();
for(List<String> addresses : List.of(to, cc, bcc))
{
addresses.removeIf(address ->
{
try
{
if(blocked.contains(new InternetAddress(address).getAddress().toLowerCase(Locale.ROOT)))
{
dropped.add(address);
return (true);
}
}
catch(Exception e)
{
// Addresses were validated above.
}
return (false);
});
}
boolean allSuppressed = to.isEmpty() && cc.isEmpty() && bcc.isEmpty();
EmailRenderer.Rendered rendered;
QRecord adHocBrand = null;
try
{
rendered = templateContent == null ? renderer.render(request) : new EmailRenderer.Rendered(templateContent.getValueString("htmlBody"), templateContent.getValueString("textBody"));
if(templateContent == null)
{
adHocBrand = com.kingsrook.qbits.email.render.EmailBrands.resolve(request.getBrandName(), null);
}
}
catch(IllegalArgumentException e)
{
return (refused(e.getMessage()));
}
Instant now = EmailRuntime.clock().instant();
QRecord row = new QRecord().withValue("providerId", provider.getValue("id"))
.withValue("status", allSuppressed ? "SUPPRESSED" : "QUEUED").withValue("finishedDate", allSuppressed ? now : null).withValue("attemptCount", 0)
.withValue("toAddresses", String.join(",", to)).withValue("ccAddresses", String.join(",", cc)).withValue("bccAddresses", String.join(",", bcc))
.withValue("fromAddress", from).withValue("fromAddressId", senderRow == null ? null : senderRow.getValue("id")).withValue("replyTo", replyTo).withValue("subject", request.getSubject())
.withValue("htmlBody", rendered.html()).withValue("textBody", rendered.text()).withValue("bodySha256", digest(rendered))
.withValue("brandId", adHocBrand == null ? null : adHocBrand.getValue("id"))
.withValue("category", category.name()).withValue("locale", request.getLocale()).withValue("idempotencyKey", request.getIdempotencyKey())
.withValue("rfcMessageId", messageId(request.getIdempotencyKey(), from))
.withValue("linkedTableName", request.getLinkedTableName()).withValue("linkedRecordId", request.getLinkedRecordId())
.withValue("senderQbit", request.getSenderQbit()).withValue("senderProcess", request.getSenderProcess())
.withValue("sendAfter", request.getSendAfter()).withValue("nextAttemptDate", request.getSendAfter() == null ? now : request.getSendAfter())
.withValue("suppressedAddresses", String.join(",", dropped));
if(templateContent != null)
{
row.getValues().putAll(templateContent.getValues());
}
for(var field : config.getSecurityFields())
{
row.setValue(field.getName(), request.getSecurityValues().get(field.getName()));
}
if(transaction != null && !sameBackend)
{
transaction.addAfterCommitCallback(() ->
{
try
{
if(insert(row, null, templateRenderer).status() == EmailSendResult.Status.REFUSED)
{
throw (new QException("Email after-commit enqueue failed"));
}
}
catch(QException e)
{
// Core logs callback failures; propagate only a sanitized message.
throw (new IllegalStateException("Email after-commit enqueue failed"));
}
});
return (new EmailSendResult(null, allSuppressed ? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED,
List.of("Enqueue deferred until caller transaction commits")));
}
return (insert(row, transaction, templateRenderer));
}
/*******************************************************************************
** Resend retained content without decrypting it or downgrading PHI policy.
** Snapshot tokens keep the original binding; the new row links to that row.
*******************************************************************************/
public static EmailSendResult resend(Integer id) throws QException
{
if(!SystemWrites.active() && !EmailPermissionChecker.has(EmailPermissions.MANAGE))
{
return (refused("Resending email needs email.manage"));
}
QRecord row = EmailStore.get(EmailTables.MESSAGE, id, null);
if(row == null || row.getValueString("htmlBody") == null && row.getValueString("textBody") == null)
{
return (refused("Retained message content is unavailable"));
}
QRecord provider = EmailStore.get(EmailTables.PROVIDER, row.getValueInteger("providerId"), null);
if(provider == null)
{
return (refused("Provider is unavailable"));
}
EmailRequest request = new EmailRequest().withSubject(row.getValueString("subject")).withHtmlBody(row.getValueString("htmlBody"))
.withTextBody(row.getValueString("textBody")).withFrom(row.getValueString("fromAddress")).withFromAddressId(row.getValueInteger("fromAddressId")).withReplyTo(row.getValueString("replyTo"))
.withProviderName(provider.getValueString("name")).withCategory(EmailCategory.valueOf(row.getValueString("category")))
.withLinkedRecord(EmailTables.MESSAGE, id).withSender("email", "email_resend");
if(row.getValueString("toAddresses") != null && !row.getValueString("toAddresses").isBlank())
{
request.withTo(row.getValueString("toAddresses"));
}
if(row.getValueString("ccAddresses") != null && !row.getValueString("ccAddresses").isBlank())
{
request.withCc(row.getValueString("ccAddresses"));
}
if(row.getValueString("bccAddresses") != null && !row.getValueString("bccAddresses").isBlank())
{
request.withBcc(row.getValueString("bccAddresses"));
}
for(var field : EmailRuntime.config().getSecurityFields())
{
request.getSecurityValues().put(field.getName(), row.getValue(field.getName()));
}
QRecord content = new QRecord();
for(String field : List.of("subject", "htmlBody", "textBody", "variablesSnapshot", "phiApproved", "templateId", "templateVersionId", "brandId", "locale", "category", "unsubscribeUrl"))
{
content.setValue(field, row.getValue(field));
}
content.withValue("encryptionSourceId", row.getValueString("id"));
return (send(request, ignored -> new EmailRenderer.Rendered(row.getValueString("htmlBody"), row.getValueString("textBody")), content));
}
/*******************************************************************************
** Insert once; the database unique key arbitrates concurrent requests.
*******************************************************************************/
private static EmailSendResult insert(QRecord row, QBackendTransaction caller, EmailTemplateRenderer templateRenderer) throws QException
{
if(caller != null)
{
return (insertInTransaction(row, caller, templateRenderer));
}
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
{
EmailSendResult result = insertInTransaction(row, transaction, templateRenderer);
if(result.status() == EmailSendResult.Status.REFUSED)
{
transaction.rollback();
}
else
{
transaction.commit();
return (result);
}
}
QRecord existing = duplicate(row.getValueString("idempotencyKey"), null);
return (existing == null ? refused("Could not queue the email message") : new EmailSendResult(existing.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
}
/*******************************************************************************
** Insert using the caller transaction, returning only safe diagnostics.
*******************************************************************************/
private static EmailSendResult insertInTransaction(QRecord row, QBackendTransaction transaction, EmailTemplateRenderer templateRenderer) throws QException
{
// A shared provider-row gate prevents a duplicate-key failure from aborting
// a caller-owned SQL transaction. The unique index remains the final arbiter.
if(row.getValueString("idempotencyKey") != null)
{
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(1), transaction);
if(!providers.isEmpty())
{
EmailStore.lockProvider(providers.get(0).getValueInteger("id"), transaction);
}
}
QRecord existing = duplicate(row.getValueString("idempotencyKey"), transaction);
if(existing != null)
{
return (new EmailSendResult(existing.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
}
QRecord inserted = SystemWrites.insert(EmailTables.MESSAGE, List.of(row), transaction).getRecords().get(0);
if(inserted.getErrors() != null && !inserted.getErrors().isEmpty())
{
throw (new QException("Could not queue the email message"));
}
try
{
String unsubscribe = row.getValueString("unsubscribeUrl");
if(unsubscribe == null && !"SUPPRESSED".equals(row.getValueString("status"))
&& (templateRenderer != null || !Boolean.TRUE.equals(row.getValueBoolean("phiApproved"))))
{
unsubscribe = EmailUnsubscribeLinks.create(inserted.getValueString("id"), row.getValueString("toAddresses"), row.getValueString("category"));
}
QRecord content;
if(templateRenderer != null)
{
content = templateRenderer.render(inserted.getValueString("id"), unsubscribe, true);
}
else
{
content = new QRecord();
if(Boolean.TRUE.equals(row.getValueBoolean("phiApproved")))
{
for(String field : List.of("subject", "htmlBody", "textBody"))
{
content.setValue(field, com.kingsrook.qbits.email.phi.EmailBodyEncryption.rebind(row.getValueString(field), field,
row.getValueString("encryptionSourceId"), inserted.getValueString("id")));
}
}
if(unsubscribe != null && row.getValueString("unsubscribeUrl") == null)
{
String link = "<p><a href=\"" + com.kingsrook.qbits.email.template.EmailTemplateEngine.escape(unsubscribe) + "\">Unsubscribe</a></p>";
String html = row.getValueString("htmlBody");
java.util.regex.Matcher end = java.util.regex.Pattern.compile("</footer>|</body>", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(html);
content.withValue("htmlBody", end.find() ? html.substring(0, end.start()) + link + html.substring(end.start()) : html + link)
.withValue("textBody", row.getValueString("textBody") + "\nUnsubscribe (" + unsubscribe + ")");
}
}
content.withValue("id", inserted.getValue("id")).withValue("unsubscribeUrl", unsubscribe);
if(content.getValueString("htmlBody") != null)
{
content.withValue("bodySha256", digest(new EmailRenderer.Rendered(content.getValueString("htmlBody"), content.getValueString("textBody"))));
}
EmailStore.update(EmailTables.MESSAGE, content, transaction);
}
catch(IllegalArgumentException e)
{
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", inserted.getValueInteger("id")), transaction);
return (refused(e.getMessage()));
}
return (new EmailSendResult(inserted.getValueInteger("id"), "SUPPRESSED".equals(row.getValueString("status"))
? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED, templateRenderer == null ? List.of() : templateRenderer.warnings()));
}
/*******************************************************************************
** Look up an idempotency key in the appropriate transaction.
*******************************************************************************/
private static QRecord duplicate(String key, QBackendTransaction transaction) throws QException
{
if(key == null)
{
return (null);
}
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)), transaction);
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Suppression matching; opt-outs never suppress transactional or security mail.
*******************************************************************************/
private static Set<String> suppressed(EmailCategory category, QBackendTransaction transaction) throws QException
{
Set<String> result = new LinkedHashSet<>();
boolean optOut = category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING;
for(QRecord row : SystemWrites.query(EmailTables.SUPPRESSION, new QQueryFilter(), transaction))
{
Instant expires = row.getValueInstant("expiresDate");
if(expires != null && !expires.isAfter(EmailRuntime.clock().instant()))
{
continue;
}
if(!optOut && "UNSUBSCRIBE".equals(row.getValueString("reason")))
{
continue;
}
if("ALL".equals(row.getValueString("scope")) || (optOut && category.name().equals(row.getValueString("scope"))))
{
result.add(row.getValueString("address").toLowerCase(Locale.ROOT));
}
}
return (result);
}
/*******************************************************************************
** The provider sender-address row a request uses, or null for the provider's own
** from address. A chosen id, or a from text, must name an enabled row of this
** provider; once a provider has rows, an explicit from text must match one.
** With neither, an enabled default row wins over the provider's from address.
*******************************************************************************/
private static QRecord senderRow(QRecord provider, List<QRecord> rows, EmailRequest request)
{
String refusal = "That from address is not set up for provider " + provider.getValueString("name");
String wanted = null;
if(request.getFrom() != null && (request.getFromAddressId() != null || !rows.isEmpty()))
{
try
{
wanted = new InternetAddress(request.getFrom()).getAddress().toLowerCase(Locale.ROOT);
}
catch(Exception e)
{
throw (new IllegalArgumentException("One or more email addresses are invalid"));
}
}
for(QRecord row : rows)
{
boolean enabled = Boolean.TRUE.equals(row.getValueBoolean("enabled"));
if(request.getFromAddressId() != null)
{
if(request.getFromAddressId().equals(row.getValueInteger("id")))
{
if(!enabled || wanted != null && !wanted.equals(row.getValueString("address")))
{
throw (new IllegalArgumentException(refusal));
}
return (row);
}
}
else if(wanted != null && enabled && wanted.equals(row.getValueString("address")))
{
return (row);
}
else if(request.getFrom() == null && enabled && Boolean.TRUE.equals(row.getValueBoolean("isDefault")))
{
return (row);
}
}
if(request.getFromAddressId() != null || wanted != null)
{
throw (new IllegalArgumentException(refusal));
}
return (null);
}
/*******************************************************************************
** Strict address parsing, rejecting header injection and local-only addresses.
*******************************************************************************/
public static List<String> addresses(List<String> input) throws Exception
{
List<String> result = new ArrayList<>();
for(String value : input)
{
if(value == null || value.contains("\r") || value.contains("\n"))
{
throw (new IllegalArgumentException("Invalid address"));
}
for(InternetAddress address : InternetAddress.parse(value, true))
{
address.validate();
if(address.isGroup() || !address.getAddress().contains("@"))
{
throw (new IllegalArgumentException("Invalid address"));
}
result.add(address.toString());
}
}
return (result);
}
/*******************************************************************************
** One sender or reply-to mailbox.
*******************************************************************************/
private static String single(String value) throws Exception
{
List<String> addresses = addresses(List.of(value));
if(addresses.size() != 1)
{
throw (new IllegalArgumentException("One address required"));
}
return (addresses.get(0));
}
/*******************************************************************************
** Keep a digest after body retention clears both alternatives.
*******************************************************************************/
private static String digest(EmailRenderer.Rendered rendered)
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((rendered.html() + "\u0000" + rendered.text()).getBytes(StandardCharsets.UTF_8))));
}
catch(java.security.NoSuchAlgorithmException e)
{
throw (new IllegalStateException("SHA-256 unavailable"));
}
}
/*******************************************************************************
** Create once before insertion. A UUID seeds messages without an idempotency key.
** The mailbox parser ignores display names; malformed domains use a local fallback.
*******************************************************************************/
static String messageId(String key, String from)
{
String domain = "qbit-email.local";
try
{
String address = new InternetAddress(from).getAddress();
String candidate = address.substring(address.lastIndexOf('@') + 1);
if(address.contains("@") && candidate.matches("[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*"))
{
domain = candidate.toLowerCase(Locale.ROOT);
}
}
catch(Exception e)
{
// A fallback also supports callers preparing legacy message rows.
}
try
{
String seed = key == null ? "qbit-email:" + UUID.randomUUID() : key;
String token = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(seed.getBytes(StandardCharsets.UTF_8))).substring(0, 32);
return ("<" + token + "@" + domain + ">");
}
catch(java.security.NoSuchAlgorithmException e)
{
throw (new IllegalStateException("SHA-256 unavailable"));
}
}
/*******************************************************************************
** Refusal with no stored row.
*******************************************************************************/
private static EmailSendResult refused(String reason)
{
return (new EmailSendResult(null, EmailSendResult.Status.REFUSED, List.of(reason)));
}
}
@@ -1,128 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.permissions;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.processes.EmailProcesses;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
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.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Maps every action on the qbit's tables, processes, widgets and app to one
** of {@code email.view}, {@code email.manage} or {@code email.admin}
** (admin implies manage, manage implies view).
**
** <p>Deliveries and attempts are written only by the qbit (its own writes run
** in an authorized scope); people and the API can read them and act on them
** through the processes.</p>
*******************************************************************************/
public class EmailPermissionChecker implements CustomPermissionChecker
{
/*******************************************************************************
** Check, or throw.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) throws QPermissionDeniedException
{
String needed = required(actionInput, metaData);
if(needed == null)
{
throw (new QPermissionDeniedException("Email messages and attempts are written only by the email qbit. Use Send Now, Resend or Cancel."));
}
if(!has(needed))
{
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
}
}
/*******************************************************************************
** The permission an action needs (null: nobody may do it directly).
*******************************************************************************/
static String required(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData)
{
boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput;
if(metaData instanceof QTableMetaData table)
{
if(EmailTables.PROVIDER.equals(table.getName()) || EmailTables.SENDER.equals(table.getName()))
{
return (EmailPermissions.ADMIN);
}
if(!write)
{
return (EmailPermissions.VIEW);
}
if(EmailTables.SYSTEM_WRITTEN.contains(table.getName()))
{
return (null);
}
if(EmailTables.PROVIDER.equals(table.getName()))
{
return (EmailPermissions.ADMIN);
}
return (EmailPermissions.MANAGE);
}
if(metaData instanceof QProcessMetaData process)
{
return (EmailProcesses.permissionFor(process.getName()));
}
return (EmailPermissions.VIEW);
}
/*******************************************************************************
** True when the current session holds the permission (or a higher one).
*******************************************************************************/
public static boolean has(String permission)
{
QSession session = QContext.getQSession();
if(session == null)
{
return (false);
}
if(session.hasPermission(EmailPermissions.ADMIN))
{
return (true);
}
if(EmailPermissions.ADMIN.equals(permission))
{
return (false);
}
if(session.hasPermission(EmailPermissions.MANAGE))
{
return (true);
}
return (session.hasPermission(permission));
}
}
@@ -1,49 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.permissions;
import java.util.List;
/*******************************************************************************
** Email permissions: view, send, manage and admin, plus the derived app access.
** Administrators imply all permissions; managers may also send and view.
*******************************************************************************/
public final class EmailPermissions
{
public static final String SEND = "email.send";
public static final String VIEW = "email.view";
public static final String MANAGE = "email.manage";
public static final String ADMIN = "email.admin";
public static final String HAS_ACCESS = "email.hasAccess";
public static final List<String> ALL = List.of(VIEW, SEND, MANAGE, ADMIN, HAS_ACCESS);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailPermissions()
{
}
}
@@ -1,137 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import com.kingsrook.qbits.email.EmailRuntime;
/*******************************************************************************
** Stateless AES-256-GCM body protection. Only the dispatcher decrypts content.
*******************************************************************************/
public final class EmailBodyEncryption
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBodyEncryption()
{
}
/*******************************************************************************
** Resolve the deployment key, never retaining it between operations.
*******************************************************************************/
private static SecretKeySpec key() throws Exception
{
String secret = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_BODY_KEY");
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
return (new SecretKeySpec(MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)), "AES"));
}
/*******************************************************************************
** Encrypt with a fresh 96-bit nonce and 128-bit authentication tag.
*******************************************************************************/
public static String encrypt(String value, String column, String messageId)
{
try
{
SecretKeySpec key = key();
if(value == null)
{
return (null);
}
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
return ("enc:v1:" + Base64.getUrlEncoder().withoutPadding().encodeToString(ByteBuffer.allocate(iv.length + encrypted.length).put(iv).put(encrypted).array()));
}
catch(Exception e)
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
}
/*******************************************************************************
** Authenticate before exposing plaintext. Failures never echo ciphertext.
*******************************************************************************/
public static String decrypt(String value, String column, String messageId)
{
if(value == null || !value.startsWith("enc:v1:"))
{
return (value);
}
try
{
ByteBuffer bytes = ByteBuffer.wrap(Base64.getUrlDecoder().decode(value.substring(7)));
byte[] iv = new byte[12];
bytes.get(iv);
byte[] encrypted = new byte[bytes.remaining()];
bytes.get(encrypted);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
return (new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Encrypted email body could not be opened"));
}
}
/*******************************************************************************
** Bind ciphertext to its storage table, column and message identity.
*******************************************************************************/
private static byte[] aad(String column, String messageId)
{
String storageColumn = switch(column)
{
case "subject" -> "subject";
case "htmlBody" -> "html_body";
case "textBody" -> "text_body";
default -> throw (new IllegalArgumentException("Unknown protected email column"));
};
return (("email_message\n" + storageColumn + "\n" + java.util.Objects.requireNonNull(messageId)).getBytes(StandardCharsets.UTF_8));
}
/*******************************************************************************
** Authenticate and rebind retained ciphertext without exposing plaintext to
** the resend caller. Only delivery callers receive decrypted content.
*******************************************************************************/
public static String rebind(String value, String column, String oldMessageId, String newMessageId)
{
return (encrypt(decrypt(value, column, oldMessageId), column, newMessageId));
}
}
@@ -1,109 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** STAND-IN(core=#1142, removeBy=RC.7). Core classification wins when available;
** overrides then schema declarations provide the RC.5 compatibility contract.
*******************************************************************************/
public final class EmailClassifications
{
/*******************************************************************************
** Ordered data sensitivity.
*******************************************************************************/
public enum Level
{
PUBLIC, CONFIDENTIAL, PII, PHI
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailClassifications()
{
}
/*******************************************************************************
** Resolve a field without a compile-time dependency on unreleased core.
*******************************************************************************/
public static Level ofField(QTableMetaData table, String field, String declared, EmailQBitConfig config)
{
if(table != null && field != null)
{
try
{
Class<?> core = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications");
Object value = core.getMethod("ofField", QTableMetaData.class, String.class).invoke(null, table, field);
if(value instanceof Enum<?> level)
{
return (Level.valueOf(level.name()));
}
}
catch(ClassNotFoundException e)
{
// Expected on RC.5; use the explicit stand-in configuration.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
throw (new IllegalArgumentException("Field classification is unavailable"));
}
Level override = config.getClassificationOverrides().get(table.getName() + "." + field);
if(override != null)
{
return (override);
}
}
return (declared == null ? Level.PUBLIC : Level.valueOf(declared));
}
/*******************************************************************************
** Schema declarations alone cannot attest a record's classification source.
*******************************************************************************/
public static boolean hasSource(QTableMetaData table, String field, EmailQBitConfig config)
{
if(config.getClassificationOverrides().containsKey(table.getName() + "." + field))
{
return (true);
}
try
{
Class<?> core = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications");
return (core.getMethod("ofField", QTableMetaData.class, String.class).invoke(null, table, field) instanceof Enum<?>);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** PII has the same email approval requirement as PHI.
*******************************************************************************/
public static boolean sensitive(Level level)
{
return (level == Level.PII || level == Level.PHI);
}
}
@@ -1,149 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.Serializable;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** STAND-IN(core=#1161-impl, removeBy=RC.7). Mirrors ClassifiedValueTokens.
** Tokens are comparisons, never recoverable snapshots. No secret is retained.
*******************************************************************************/
public final class EmailValueTokens
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailValueTokens()
{
}
/*******************************************************************************
** Bind to table, field and record identity. TODO core: wipe-rule record
** fields will produce shred:v1; context values must never use shred:v1.
*******************************************************************************/
public static String forRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value)
{
return (token(List.of(table.getName(), field.getName(), String.valueOf(pk)), value));
}
/*******************************************************************************
** Non-record low-entropy inputs cannot yield dictionary-testable tokens.
*******************************************************************************/
public static String forContext(List<String> bindingParts, Serializable value)
{
if(value instanceof Boolean || canonical(value).codePoints().distinct().count() < 4)
{
return (changed());
}
return (token(bindingParts, value));
}
/*******************************************************************************
** Deliberately non-verifiable marker.
*******************************************************************************/
public static String changed()
{
return ("changed:v1");
}
/*******************************************************************************
** Verify only a genuine keyed token, with constant-time comparison.
*******************************************************************************/
public static boolean verifyRecordField(QTableMetaData table, QFieldMetaData field, Serializable pk, Serializable value, String token)
{
return (matches(token, forRecordField(table, field, pk, value)));
}
/*******************************************************************************
** Verify a context token, never changed or shred markers.
*******************************************************************************/
public static boolean verifyContext(List<String> bindingParts, Serializable value, String token)
{
return (matches(token, forContext(bindingParts, value)));
}
/*******************************************************************************
** Stable canonical representation shared with future core.
*******************************************************************************/
public static String canonical(Serializable value)
{
if(value instanceof BigDecimal decimal)
{
return (decimal.stripTrailingZeros().toPlainString());
}
return (ValueUtils.getValueAsString(value));
}
/*******************************************************************************
** Construct the versioned HMAC without logging key or input.
*******************************************************************************/
private static String token(List<String> parts, Serializable value)
{
try
{
String secret = EmailRuntime.config().getSecretResolver().apply("QQQ_AUDIT_HMAC_KEY");
if(secret == null || secret.isBlank())
{
return (changed());
}
byte[] key = Base64.getDecoder().decode(secret);
String keyId = EmailRuntime.config().getSecretResolver().apply("QQQ_AUDIT_HMAC_KEY_ID");
if(keyId == null || keyId.isBlank())
{
keyId = "env1";
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
List<String> binding = new ArrayList<>(parts);
binding.add(canonical(value));
return ("hmac:v1:" + keyId + ":" + Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(String.join("\u001f", binding).getBytes(StandardCharsets.UTF_8))));
}
catch(Exception e)
{
// An unavailable or malformed audit key must not reveal the value.
return (changed());
}
}
/*******************************************************************************
** Compare only HMAC tokens, never sentinel strings.
*******************************************************************************/
private static boolean matches(String supplied, String computed)
{
return (supplied != null && supplied.startsWith("hmac:v1:") && computed.startsWith("hmac:v1:")
&& MessageDigest.isEqual(supplied.getBytes(StandardCharsets.UTF_8), computed.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -1,560 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.processes;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Standard Review/Result record actions and hidden scheduled maintenance.
*******************************************************************************/
public final class EmailProcesses
{
public static final String NEW_VERSION = "email_new_version";
public static final String ACTIVATE_VERSION = "email_activate_version";
public static final String APPROVE_PHI = "email_approve_phi";
public static final String RESEND = "email_resend";
public static final String CANCEL = "email_cancel";
public static final String SEND_NOW = "email_send_now";
public static final String SEND_TEST = "email_send_test";
public static final String SEND_AD_HOC = "email_send_ad_hoc";
public static final String SEND_TEMPLATED = "email_send_templated";
public static final String DISPATCH = "email_dispatch";
public static final String MAINTAIN = "email_maintain";
public static final String REPROCESS = "email_reprocess_webhook";
public static final String PROCESS_WEBHOOKS = "email_process_webhooks";
public static final String RETENTION = "email_retention";
/** Process value the send-test prepare step sets to the selected provider id; the from address filter falls back to it. */
public static final String PROVIDER_FILTER_VALUE = "possibleValueFilterValueId";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailProcesses()
{
}
/*******************************************************************************
** Required action permission.
*******************************************************************************/
public static String permissionFor(String name)
{
return (switch(name)
{
case REPROCESS, RESEND, CANCEL, SEND_NOW, NEW_VERSION, ACTIVATE_VERSION -> EmailPermissions.MANAGE;
case SEND_TEMPLATED, SEND_AD_HOC -> EmailPermissions.SEND;
default -> EmailPermissions.ADMIN;
});
}
/*******************************************************************************
** All actions and schedules; none is an app child.
*******************************************************************************/
public static List<QProcessMetaData> processes(EmailQBitConfig config)
{
QProcessMetaData test = action(SEND_TEST, "Send Test Email", EmailTables.PROVIDER);
test.withStep(0, new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(SendTestPrepareStep.class)));
test.withStep(1, new QFrontendStepMetaData().withName("input").withLabel("Recipient")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("toAddress", QFieldType.STRING).withLabel("To address").withIsRequired(true))
.withFormField(new QFieldMetaData("fromAddressId", QFieldType.INTEGER).withLabel("From address").withPossibleValueSourceName(EmailTables.SENDER)
.withPossibleValueSourceFilter(EmailStore.equal("providerId", "${input.id}??${processValues." + PROVIDER_FILTER_VALUE + "}"))));
QFunctionInputMetaData inputs = new QFunctionInputMetaData();
QFunctionInputMetaData adHocInputs = new QFunctionInputMetaData();
for(String field : List.of("templateName", "to", "cc", "bcc", "replyTo", "subject", "markdownBody", "htmlBody", "textBody", "providerName", "brandName",
"locale", "variablesJson", "recordVariablesJson", "linkedTableName", "linkedRecordId", "idempotencyKey"))
{
inputs.withField(new QFieldMetaData(field, QFieldType.STRING));
adHocInputs.withField(new QFieldMetaData(field, QFieldType.STRING));
}
adHocInputs.withField(new QFieldMetaData("category", QFieldType.STRING));
for(QFunctionInputMetaData target : List.of(inputs, adHocInputs))
{
target.withField(new QFieldMetaData("providerId", QFieldType.INTEGER).withPossibleValueSourceName(EmailTables.PROVIDER));
target.withField(new QFieldMetaData("fromAddressId", QFieldType.INTEGER).withPossibleValueSourceName(EmailTables.SENDER)
.withPossibleValueSourceFilter(EmailStore.equal("providerId", "${input.providerId}")));
}
QProcessMetaData adHoc = new QProcessMetaData().withName(SEND_AD_HOC).withLabel("Send Ad Hoc Email").withIsHidden(true)
.withPermissionRules(EmailTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("send").withCode(new QCodeReference(SendStep.class)).withInputData(adHocInputs));
QProcessMetaData send = new QProcessMetaData().withName(SEND_TEMPLATED).withLabel("Send Email").withIsHidden(true)
.withPermissionRules(EmailTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("send").withCode(new QCodeReference(SendStep.class)).withInputData(inputs));
return (List.of(action(RESEND, "Resend", EmailTables.MESSAGE), action(CANCEL, "Cancel", EmailTables.MESSAGE),
action(SEND_NOW, "Send Now", EmailTables.MESSAGE), test, send, adHoc,
action(NEW_VERSION, "New Version", EmailTables.TEMPLATE), action(ACTIVATE_VERSION, "Activate Version", EmailTables.VERSION),
action(APPROVE_PHI, "Approve For PHI", EmailTables.VERSION),
action(REPROCESS, "Reprocess", EmailTables.INBOX), scheduled(config, PROCESS_WEBHOOKS, "Process Email Webhooks", 30),
scheduled(config, DISPATCH, "Dispatch Email", config.getDispatchIntervalSeconds()),
scheduled(config, MAINTAIN, "Maintain Email", 300), scheduled(config, RETENTION, "Email Retention", 86400)));
}
/*******************************************************************************
** A standard streamed-ETL action.
*******************************************************************************/
private static QProcessMetaData action(String name, String label, String table)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_FETCH_HEAVY_FIELDS, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected records.");
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, ActionTransform.class, ActionLoad.class, values)
.withName(name).withLabel(label).withIcon(new QIcon("mail")).withTableName(table).withPermissionRules(EmailTables.permissionRules()));
}
/*******************************************************************************
** Hidden backend schedule; without a scheduler manual dispatch remains possible.
*******************************************************************************/
private static QProcessMetaData scheduled(EmailQBitConfig config, String name, String label, int seconds)
{
QProcessMetaData process = new QProcessMetaData().withName(name).withLabel(label).withIsHidden(true)
.withPermissionRules(EmailTables.permissionRules()).withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduledStep.class)));
if(config.getSchedulerName() != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withInitialDelaySeconds(Math.min(seconds, 30)));
}
return (process);
}
/*******************************************************************************
** Whether a selected row can participate in the action.
*******************************************************************************/
private static boolean eligible(String process, QRecord row)
{
if(REPROCESS.equals(process))
{
return ("FAILED".equals(row.getValueString("status")));
}
if(NEW_VERSION.equals(process))
{
return (true);
}
if(ACTIVATE_VERSION.equals(process) || APPROVE_PHI.equals(process))
{
return ("DRAFT".equals(row.getValueString("status")) || APPROVE_PHI.equals(process) && "ACTIVE".equals(row.getValueString("status")));
}
if(SEND_TEST.equals(process))
{
return (Boolean.TRUE.equals(row.getValueBoolean("enabled")));
}
String status = row.getValueString("status");
if(RESEND.equals(process))
{
return (List.of("DEAD_LETTER", "BOUNCED", "COMPLAINED").contains(status)
&& (row.getValueString("htmlBody") != null || row.getValueString("textBody") != null));
}
return (EmailDispatcher.DUE.contains(status));
}
/*******************************************************************************
** Review screen validation without side effects.
*******************************************************************************/
public static class ActionTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Filter rows and explain skipped records.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
boolean valid = eligible(input.getProcessName(), row);
String diagnostic = valid ? "record will be processed" : "record skipped: status or retained content does not permit this action";
if(valid && List.of(NEW_VERSION, ACTIVATE_VERSION, APPROVE_PHI).contains(input.getProcessName()))
{
try
{
QRecord template = NEW_VERSION.equals(input.getProcessName()) ? row : EmailStore.get(EmailTables.TEMPLATE, row.getValueInteger("templateId"), null);
com.kingsrook.qbits.email.template.EmailVersions.requireEdit(template, APPROVE_PHI.equals(input.getProcessName()));
if(ACTIVATE_VERSION.equals(input.getProcessName()))
{
com.kingsrook.qbits.email.template.EmailVersions.validate(template, row);
}
}
catch(IllegalArgumentException e)
{
valid = false;
diagnostic = e.getMessage();
}
}
lines.add(new ProcessSummaryLine(valid ? Status.OK : Status.INFO, 1, diagnostic));
if(valid)
{
output.getRecords().add(row);
}
}
}
/*******************************************************************************
** Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Execute only after review; re-read rows to avoid stale actions.
*******************************************************************************/
public static class ActionLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Perform and report one selected row at a time.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String process = input.getProcessName();
for(QRecord row : input.getRecords())
{
Integer id = row.getValueInteger("id");
QRecord current = EmailStore.get(NEW_VERSION.equals(process) ? EmailTables.TEMPLATE : List.of(ACTIVATE_VERSION, APPROVE_PHI).contains(process) ? EmailTables.VERSION
: REPROCESS.equals(process) ? EmailTables.INBOX : SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
String result = "Skipped";
if(current != null && eligible(process, current))
{
result = switch(process)
{
case NEW_VERSION -> "Created DRAFT " + com.kingsrook.qbits.email.template.EmailVersions.newVersion(id).getValueInteger("versionNumber");
case ACTIVATE_VERSION ->
{
com.kingsrook.qbits.email.template.EmailVersions.activate(id);
yield "ACTIVE";
}
case APPROVE_PHI ->
{
com.kingsrook.qbits.email.template.EmailVersions.approve(id);
yield "Approved for PHI";
}
case REPROCESS -> com.kingsrook.qbits.email.tracking.EmailInboxProcessor.reprocess(id) ? "PENDING" : "Skipped";
case SEND_NOW -> new EmailDispatcher().sendNow(id).name();
case CANCEL -> cancel(id) ? "CANCELLED" : "Skipped";
case RESEND -> resend(current).status().name();
case SEND_TEST -> sendTest(current, input.getValueString("toAddress"), input.getValueInteger("fromAddressId"));
default -> "Skipped";
};
}
lines.add(new ProcessSummaryLine(Status.INFO, 1, "record: " + result));
output.getRecords().add(row);
}
}
/*******************************************************************************
** Result screen lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Cancel under the same database gate as claims.
*******************************************************************************/
public static boolean cancel(Integer id) throws QException
{
QRecord row = EmailStore.get(EmailTables.MESSAGE, id, null);
if(row == null)
{
return (false);
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailStore.lockProvider(row.getValueInteger("providerId"), tx);
row = EmailStore.get(EmailTables.MESSAGE, id, tx);
if(!EmailDispatcher.DUE.contains(row.getValueString("status")))
{
return (false);
}
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "CANCELLED").withValue("finishedDate", EmailRuntime.clock().instant()).withValue("nextAttemptDate", null), tx);
tx.commit();
return (true);
}
}
/*******************************************************************************
** A fresh, linked message through suppression and provider validation again.
*******************************************************************************/
private static EmailSendResult resend(QRecord row) throws QException
{
return (EmailService.resend(row.getValueInteger("id")));
}
/*******************************************************************************
** Queue and dispatch a fixed provider test message.
*******************************************************************************/
private static String sendTest(QRecord provider, String to, Integer fromAddressId) throws QException
{
if(to == null)
{
return ("REFUSED: a to address is required");
}
EmailSendResult result = EmailService.send(new EmailRequest().withProviderName(provider.getValueString("name")).withTo(to).withFromAddressId(fromAddressId)
.withSubject("Email delivery test").withMarkdownBody("This is a test of your email provider.").withSender("email", SEND_TEST));
return (result.status() == EmailSendResult.Status.QUEUED ? new EmailDispatcher().sendNow(result.messageId()).name() : result.status().name());
}
/*******************************************************************************
** Seeds the from address possible-value filter: a record action's form has no
** record id, so a single selected provider id becomes a process value that the
** filter reads after the form's own ${input.id}.
*******************************************************************************/
public static class SendTestPrepareStep implements BackendStep
{
/*******************************************************************************
** Expose the selected provider id when exactly one provider is selected.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String ids = input.getValueString("recordIds");
if(ids != null && !ids.contains(","))
{
try
{
output.addValue(PROVIDER_FILTER_VALUE, Integer.valueOf(ids.trim()));
}
catch(NumberFormatException e)
{
// Not a single id: the filter stays open and the review step still validates the selection.
}
}
}
}
/*******************************************************************************
** Backend sending API with inputs declared as QQQ fields.
*******************************************************************************/
public static class SendStep implements BackendStep
{
/*******************************************************************************
** Map process inputs to the same service API.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String providerName = input.getValueString("providerName");
if(input.getValueInteger("providerId") != null)
{
QRecord provider = EmailStore.get(EmailTables.PROVIDER, input.getValueInteger("providerId"), null);
if(provider == null || providerName != null && !providerName.equals(provider.getValueString("name")))
{
throw (new QException("providerId does not match an existing provider named providerName"));
}
providerName = provider.getValueString("name");
}
EmailRequest request = new EmailRequest().withTemplateName(input.getValueString("templateName")).withFromAddressId(input.getValueInteger("fromAddressId"))
.withSubject(input.getValueString("subject")).withMarkdownBody(input.getValueString("markdownBody"))
.withHtmlBody(input.getValueString("htmlBody")).withTextBody(input.getValueString("textBody"))
.withReplyTo(input.getValueString("replyTo")).withProviderName(providerName)
.withBrandName(input.getValueString("brandName")).withIdempotencyKey(input.getValueString("idempotencyKey"))
.withLinkedRecord(input.getValueString("linkedTableName"), input.getValueString("linkedRecordId"));
if(input.getValueString("to") != null)
{
request.withTo(input.getValueString("to"));
}
if(input.getValueString("cc") != null)
{
request.withCc(input.getValueString("cc"));
}
if(input.getValueString("bcc") != null)
{
request.withBcc(input.getValueString("bcc"));
}
if(input.getValueString("variablesJson") != null)
{
request.withVariables(com.kingsrook.qbits.email.template.VariableSchema.values(input.getValueString("variablesJson")));
}
if(input.getValueString("locale") != null)
{
request.withLocale(input.getValueString("locale"));
}
if(input.getValueString("recordVariablesJson") != null)
{
var bindings = com.kingsrook.qbits.email.template.VariableSchema.values(input.getValueString("recordVariablesJson"));
for(var binding : bindings.entrySet())
{
if(!(binding.getValue() instanceof Map<?, ?> ref) || !(ref.get("table") instanceof String table) || !(ref.get("id") instanceof Serializable id))
{
throw (new QException("Record variables need table and id"));
}
request.withRecordVariable(binding.getKey(), table, id);
}
}
if(request.getTemplateName() == null && input.getValueString("category") != null)
{
try
{
request.withCategory(EmailCategory.valueOf(input.getValueString("category")));
}
catch(IllegalArgumentException e)
{
throw (new QException("Unknown email category"));
}
}
EmailSendResult result = EmailService.send(request);
output.addValue("messageId", result.messageId());
output.addValue("status", result.status().name());
output.addValue("reasons", String.join("; ", result.reasons()));
}
}
/*******************************************************************************
** Scheduler entry point.
*******************************************************************************/
public static class ScheduledStep implements BackendStep
{
/*******************************************************************************
** Dispatch, recover leases and probe health, or retain final messages.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
switch(input.getProcessName())
{
case PROCESS_WEBHOOKS -> new com.kingsrook.qbits.email.tracking.EmailInboxProcessor().runOnce();
case RETENTION -> retain();
case MAINTAIN -> new EmailDispatcher().maintain();
default -> new EmailDispatcher().runOnce();
}
}
}
/*******************************************************************************
** Clear retained bodies and optionally delete final messages and their attempts.
** NOTIFICATION and MARKETING messages are never deleted (their unsubscribe links must
** keep working); at message retention only their bodies and variable snapshot are cleared.
*******************************************************************************/
public static void retain() throws QException
{
EmailQBitConfig config = EmailRuntime.config();
Instant now = EmailRuntime.clock().instant();
for(QRecord row : SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, new ArrayList<>(EmailDispatcher.FINAL)))))
{
Instant finalDate = row.getValueInstant("finishedDate");
if(finalDate == null)
{
finalDate = row.getValueInstant("sentDate");
}
if(finalDate == null)
{
finalDate = row.getValueInstant("deadLetterDate");
}
if(finalDate == null)
{
finalDate = row.getValueInstant("createDate");
}
if(finalDate == null)
{
continue;
}
boolean expired = config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays())));
boolean optOut = List.of("NOTIFICATION", "MARKETING").contains(row.getValueString("category"));
if(expired && !optOut)
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(EmailTables.MESSAGE)))
{
if(row.getValueInteger("providerId") != null)
{
com.kingsrook.qbits.email.tracking.EmailTrackingStore.lock(row.getValueInteger("providerId"), transaction);
}
SystemWrites.delete(EmailTables.EVENT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
SystemWrites.delete(EmailTables.ATTEMPT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", row.getValueInteger("id")), transaction);
transaction.commit();
}
}
else if(expired || finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))))
{
// Notification and marketing rows are never deleted, so their unsubscribe links keep working; only content goes.
boolean clearSnapshot = expired && row.getValue("variablesSnapshot") != null;
if(row.getValue("htmlBody") != null || row.getValue("textBody") != null || clearSnapshot)
{
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null);
if(clearSnapshot)
{
update.withValue("variablesSnapshot", null);
}
SystemWrites.updateOne(EmailTables.MESSAGE, update);
}
}
}
}
}
@@ -1,112 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** WCAG relative luminance and AA contrast for both email color schemes.
*******************************************************************************/
public final class ContrastCheck
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ContrastCheck()
{
}
/*******************************************************************************
** Check every actual foreground/background pair in the branded shell.
*******************************************************************************/
public static void validate(QRecord brand)
{
check("Text on background", color(brand, "textColor"), color(brand, "backgroundColor"), "light");
check("Text on background", color(brand, "darkTextColor"), color(brand, "darkBackgroundColor"), "dark");
check("Button text on primary", color(brand, "buttonTextColor"), color(brand, "primaryColor"), "light");
check("Button text on primary", color(brand, "darkButtonTextColor"), color(brand, "darkPrimaryColor"), "dark");
}
/*******************************************************************************
** Defaults are neutral and accessible; reject anything except hex colors.
*******************************************************************************/
public static String color(QRecord brand, String field)
{
String value = brand.getValueString(field);
if(value == null || value.isBlank())
{
value = switch(field)
{
case "backgroundColor", "buttonTextColor", "darkTextColor" -> "#ffffff";
case "primaryColor" -> "#2459a6";
case "darkPrimaryColor" -> color(brand, "primaryColor");
case "darkButtonTextColor" -> ratio(color(brand, "buttonTextColor"), color(brand, "darkPrimaryColor")) >= 4.5
? color(brand, "buttonTextColor") : ratio("#000000", color(brand, "darkPrimaryColor")) >= 4.5 ? "#000000" : "#ffffff";
default -> "#171717";
};
}
if(!value.matches("#[0-9a-fA-F]{6}"))
{
throw (new IllegalArgumentException("Brand colors must use six-digit hex values"));
}
return (value);
}
/*******************************************************************************
** Contrast is independent of which color is lighter.
*******************************************************************************/
public static double ratio(String first, String second)
{
double a = luminance(first);
double b = luminance(second);
return ((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05));
}
/*******************************************************************************
** sRGB linearization and weighted luminance.
*******************************************************************************/
private static double luminance(String color)
{
double[] weights = {0.2126, 0.7152, 0.0722};
double result = 0;
for(int i = 0; i < 3; i++)
{
double channel = Integer.parseInt(color.substring(1 + i * 2, 3 + i * 2), 16) / 255.0;
result += weights[i] * (channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4));
}
return (result);
}
/*******************************************************************************
** Author-facing refusal explains the failing scheme and threshold.
*******************************************************************************/
private static void check(String label, String foreground, String background, String scheme)
{
double ratio = ratio(foreground, background);
if(ratio < 4.5)
{
throw (new IllegalArgumentException(String.format(Locale.ROOT, "%s is %.1f:1 in %s mode; it needs 4.5:1", label, ratio, scheme)));
}
}
}
@@ -1,156 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.List;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Brand selection shared by sends, activation and previews.
*******************************************************************************/
public final class EmailBrands
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBrands()
{
}
/*******************************************************************************
** Explicit request, template, app brand, default brand, then neutral.
*******************************************************************************/
public static QRecord resolve(String requested, QRecord template) throws QException
{
if(requested != null)
{
List<QRecord> rows = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("name", requested));
if(rows.isEmpty())
{
throw (new IllegalArgumentException("Unknown email brand"));
}
return (rows.getFirst());
}
if(template != null && template.getValueInteger("brandId") != null)
{
QRecord brand = EmailStore.get(EmailTables.BRAND, template.getValueInteger("brandId"), null);
if(brand == null)
{
throw (new IllegalArgumentException("Template brand is missing"));
}
return (brand);
}
List<QRecord> brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("appName", EmailRuntime.config().getAppName()));
if(brands.isEmpty())
{
brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("isDefault", true));
}
if(brands.size() > 1)
{
throw (new IllegalArgumentException("More than one default email brand matches"));
}
return (brands.isEmpty() ? new QRecord().withValue("label", EmailRuntime.config().getAppLabel()).withValue("name", "neutral") : brands.getFirst());
}
/*******************************************************************************
** Refuse unsafe URLs and inaccessible colors before a brand is saved.
*******************************************************************************/
public static void validate(QRecord brand)
{
ContrastCheck.validate(brand);
for(String field : List.of("logoUrl", "logoDarkUrl", "siteUrl"))
{
String value = brand.getValueString(field);
if(value != null && !value.isBlank())
{
VariableSchema.url(value);
if(!"siteUrl".equals(field))
{
https(value);
}
}
}
social(brand);
}
/*******************************************************************************
** Parse the small social-link list with strict web URLs.
*******************************************************************************/
public static List<java.util.Map<String, String>> social(QRecord brand)
{
try
{
String json = brand.getValueString("socialLinks");
if(json == null || json.isBlank())
{
return (List.of());
}
var links = new com.fasterxml.jackson.databind.ObjectMapper().readValue(json,
new com.fasterxml.jackson.core.type.TypeReference<List<java.util.Map<String, String>>>() {});
links.forEach(link -> https(link.get("url")));
return (links);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Social links need labels and https URLs"));
}
}
/*******************************************************************************
** Logos and social destinations require HTTPS at the save boundary.
*******************************************************************************/
private static void https(String value)
{
VariableSchema.url(value);
if(!"https".equalsIgnoreCase(java.net.URI.create(value).getScheme()))
{
throw (new IllegalArgumentException("Brand logo and social URLs must use https"));
}
}
/*******************************************************************************
** Approved messages restrict every image to the brand site or an explicit host.
*******************************************************************************/
public static boolean allowedImage(String value, QRecord brand)
{
try
{
https(value);
String host = java.net.URI.create(value).getHost();
String site = brand.getValueString("siteUrl");
return (EmailRuntime.config().getAllowedImageHosts().contains(host.toLowerCase(java.util.Locale.ROOT))
|| site != null && host.equalsIgnoreCase(java.net.URI.create(site).getHost()));
}
catch(IllegalArgumentException e)
{
return (false);
}
}
}
@@ -1,135 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qbits.email.template.VariableSchema;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Single-column, 600px email shell with inline fallbacks and dark-mode hooks.
*******************************************************************************/
public final class EmailLayout
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailLayout()
{
}
/*******************************************************************************
** Render both schemes from the same validated brand.
*******************************************************************************/
public static String render(String body, String preheader, QRecord brand, EmailCategory category, String unsubscribe)
{
EmailBrands.validate(brand);
String dark = ".email-shell,.email-content{background-color:" + ContrastCheck.color(brand, "darkBackgroundColor")
+ "!important;color:" + ContrastCheck.color(brand, "darkTextColor") + "!important}.email-button{background-color:"
+ ContrastCheck.color(brand, "darkPrimaryColor") + "!important}.email-button a{color:" + ContrastCheck.color(brand, "darkButtonTextColor")
+ "!important}.email-logo-light{display:none!important}.email-logo-dark{display:block!important}";
String outlook = "[data-ogsc] .email-content,[data-ogsc] .email-shell,[data-ogsb] .email-content,[data-ogsb] .email-shell{background-color:" + ContrastCheck.color(brand, "darkBackgroundColor")
+ "!important;color:" + ContrastCheck.color(brand, "darkTextColor") + "!important}[data-ogsc] .email-button,[data-ogsb] .email-button{background-color:"
+ ContrastCheck.color(brand, "darkPrimaryColor") + "!important}[data-ogsc] .email-button a,[data-ogsb] .email-button a{color:" + ContrastCheck.color(brand, "darkButtonTextColor")
+ "!important}[data-ogsc] .email-logo-light,[data-ogsb] .email-logo-light{display:none!important}[data-ogsc] .email-logo-dark,[data-ogsb] .email-logo-dark{display:block!important}";
return ("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<meta name=\"color-scheme\" content=\"light dark\"><meta name=\"supported-color-schemes\" content=\"light dark\">"
+ "<style>:root{color-scheme:light dark;supported-color-schemes:light dark}@media(max-width:640px){.email-content{padding:16px!important;width:100%!important}}"
+ "@media(prefers-color-scheme:dark){" + dark + "}" + outlook + "</style></head><body class=\"email-shell\" style=\"margin:0;padding:0;background-color:"
+ ContrastCheck.color(brand, "backgroundColor") + ";color:" + ContrastCheck.color(brand, "textColor") + "\">"
+ "<div style=\"display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all\">" + EmailTemplateEngine.escape(preheader) + "</div>"
+ "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr><td align=\"center\">"
+ "<!--[if mso]><table role=\"presentation\" width=\"600\"><tr><td><![endif]-->"
+ "<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"max-width:600px;table-layout:fixed\"><tr>"
+ "<td class=\"email-content\" style=\"padding:24px;font-family:Arial,sans-serif;line-height:1.6;overflow-wrap:anywhere;word-break:break-word;background-color:"
+ ContrastCheck.color(brand, "backgroundColor") + ";color:" + ContrastCheck.color(brand, "textColor") + "\">" + logos(brand)
+ body + footer(brand, category, unsubscribe) + "</td></tr></table><!--[if mso]></td></tr></table><![endif]--></td></tr></table></body></html>");
}
/*******************************************************************************
** Logo alternatives, with dimensions that never force horizontal overflow.
*******************************************************************************/
private static String logos(QRecord brand)
{
String light = brand.getValueString("logoUrl");
if(light == null || light.isBlank())
{
return ("<p><strong>" + EmailTemplateEngine.escape(brand.getValueString("label")) + "</strong></p>");
}
String dark = brand.getValueString("logoDarkUrl");
return (logo(light, brand.getValueString("logoAlt"), dark == null ? "" : "email-logo-light", "block")
+ (dark == null ? "" : logo(dark, brand.getValueString("logoAlt"), "email-logo-dark", "none")));
}
/*******************************************************************************
** Escaped logo element.
*******************************************************************************/
private static String logo(String url, String alt, String css, String display)
{
return ("<img class=\"" + css + "\" src=\"" + EmailTemplateEngine.escape(VariableSchema.url(url)) + "\" alt=\""
+ EmailTemplateEngine.escape(alt) + "\" width=\"180\" style=\"display:" + display + ";max-width:100%;height:auto\">");
}
/*******************************************************************************
** Category-specific footer, including postal address for marketing.
*******************************************************************************/
public static String footer(QRecord brand, EmailCategory category, String unsubscribe)
{
StringBuilder footer = new StringBuilder("<footer style=\"margin-top:24px;font-size:12px;overflow-wrap:anywhere\">");
footer.append(EmailMarkdown.render(brand.getValueString("footerMarkdown"), brand));
if(category == EmailCategory.MARKETING)
{
String address = brand.getValueString("postalAddress");
if(address == null || address.isBlank())
{
throw (new IllegalArgumentException("Marketing email needs a brand postal address"));
}
footer.append("<p>").append(EmailTemplateEngine.escape(address)).append("</p>");
}
for(var link : EmailBrands.social(brand))
{
footer.append("<a style=\"color:inherit\" href=\"").append(EmailTemplateEngine.escape(link.get("url"))).append("\">")
.append(EmailTemplateEngine.escape(link.get("label"))).append("</a> ");
}
if((category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING) && unsubscribe != null && !unsubscribe.isBlank())
{
footer.append("<p><a style=\"color:inherit\" href=\"").append(EmailTemplateEngine.escape(unsubscribe)).append("\">Unsubscribe</a></p>");
}
return (footer.append("</footer>").toString());
}
/*******************************************************************************
** HTML and compiled MJML retain their shell; append opt-out before body end.
*******************************************************************************/
public static String appendFooter(String html, QRecord brand, EmailCategory category, String unsubscribe)
{
if(category != EmailCategory.NOTIFICATION && category != EmailCategory.MARKETING)
{
return (html);
}
String footer = footer(brand, category, unsubscribe);
java.util.regex.Matcher end = java.util.regex.Pattern.compile("</body\\s*>", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(html);
return (end.find() ? html.substring(0, end.start()) + footer + html.substring(end.start()) : html + footer);
}
}
@@ -1,71 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
/*******************************************************************************
** CommonMark with GFM tables, autolinks and a constrained button attribute.
*******************************************************************************/
public final class EmailMarkdown
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailMarkdown()
{
}
/*******************************************************************************
** Raw HTML is escaped and URLs sanitized before adding table buttons.
*******************************************************************************/
public static String render(String markdown, QRecord brand)
{
var extensions = List.of(TablesExtension.create(), AutolinkExtension.create());
String html = HtmlRenderer.builder().escapeHtml(true).sanitizeUrls(true).extensions(extensions).build()
.render(Parser.builder().extensions(extensions).build().parse(markdown == null ? "" : markdown))
.replace("<a ", "<a style=\"color:inherit;text-decoration:underline\" ");
Matcher links = Pattern.compile("<a[^>]* href=\"([^\"]*)\"[^>]*>((?:(?!</?a\\b)[^\\r\\n])*)</a>\\{\\.button}").matcher(html);
StringBuilder result = new StringBuilder();
while(links.find())
{
String button = "<table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\"><tr><td class=\"email-button\" bgcolor=\""
+ ContrastCheck.color(brand, "primaryColor") + "\" style=\"border-radius:4px;padding:12px 20px;background-color:"
+ ContrastCheck.color(brand, "primaryColor") + "\"><a href=\"" + links.group(1) + "\" style=\"display:inline-block;text-decoration:none;color:"
+ ContrastCheck.color(brand, "buttonTextColor") + ";font-weight:bold\">" + links.group(2) + "</a></td></tr></table>";
links.appendReplacement(result, Matcher.quoteReplacement(button));
}
links.appendTail(result);
return (result.toString().replace("<p><table", "<table").replace("</table></p>", "</table>").replace("<table>", "<table style=\"width:100%;table-layout:fixed;border-collapse:collapse\">")
.replace("<td>", "<td style=\"padding:6px;border:1px solid #888;overflow-wrap:anywhere\">")
.replace("<th>", "<th style=\"padding:6px;border:1px solid #888;overflow-wrap:anywhere\">")
.replace("<img ", "<img style=\"max-width:100%;height:auto\" ").replace("<pre>", "<pre style=\"white-space:pre-wrap;overflow-wrap:anywhere\">"));
}
}
@@ -1,150 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.template.VariableSchema;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** Queue-time link signing and verification from DESIGN 8.2.
*******************************************************************************/
public final class EmailUnsubscribeLinks
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailUnsubscribeLinks()
{
}
/*******************************************************************************
** Bind the message, single mailbox hash and category without exposing PII.
** No key means no link/header; marketing must refuse rather than omit opt-out.
*******************************************************************************/
public static String create(String messageId, String recipient, String category)
{
if(!"NOTIFICATION".equals(category) && !"MARKETING".equals(category))
{
return (null);
}
String key;
try
{
key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
}
catch(Exception e)
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
String base = EmailRuntime.config().getPublicBaseUrl();
if(key == null || key.isBlank() || base == null || base.isBlank())
{
if("MARKETING".equals(category))
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
return (null);
}
String address = recipient(recipient, null, null);
try
{
VariableSchema.url(base);
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(address.getBytes(StandardCharsets.UTF_8)));
String payload = Base64.getUrlEncoder().withoutPadding().encodeToString(VariableSchema.json(Map.of("messageId", messageId, "addressHash", hash, "category", category)).getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signed = "v1." + payload;
String signature = Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)));
return (base.replaceAll("/+$", "") + EmailRuntime.config().getWebhookBasePath() + "/unsubscribe/" + signed + "." + signature);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
}
}
/*******************************************************************************
** Exactly one To mailbox, canonicalized for signing and category suppression.
*******************************************************************************/
public static String recipient(String to, String cc, String bcc)
{
try
{
InternetAddress[] addresses = InternetAddress.parse(to == null ? "" : to, true);
if(addresses.length == 1 && !addresses[0].isGroup() && addresses[0].getAddress().contains("@")
&& (cc == null || cc.isBlank()) && (bcc == null || bcc.isBlank()))
{
addresses[0].validate();
return (addresses[0].getAddress().toLowerCase(Locale.ROOT));
}
}
catch(jakarta.mail.internet.AddressException e)
{
// Malformed stored envelopes cannot authorize an opt-out.
}
throw (new IllegalArgumentException("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient."));
}
/*******************************************************************************
** Verify the signed format before any lookup or side effect.
** Invalid and non-opt-out categories are indistinguishable to callers.
*******************************************************************************/
public static org.json.JSONObject verify(String token)
{
try
{
if(token == null || token.length() > 4096)
{
return (null);
}
String[] parts = token.split("\\.", -1);
String key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
if(parts.length != 3 || !"v1".equals(parts[0]) || key == null || key.isBlank())
{
return (null);
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
if(!MessageDigest.isEqual(mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)), Base64.getUrlDecoder().decode(parts[2])))
{
return (null);
}
var payload = new org.json.JSONObject(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
String category = payload.getString("category");
return (("MARKETING".equals(category) || "NOTIFICATION".equals(category)) ? payload : null);
}
catch(Exception e)
{
return (null);
}
}
}
@@ -1,132 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.render;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.parser.ParserDelegator;
import java.io.StringReader;
/*******************************************************************************
** HTML to readable text using the JDK parser (including entity decoding).
*******************************************************************************/
public final class HtmlToText
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private HtmlToText()
{
}
/*******************************************************************************
** Keep links, headings, lists and table rows; omit scripts and styles.
*******************************************************************************/
public static String convert(String html)
{
StringBuilder text = new StringBuilder();
try
{
new ParserDelegator().parse(new StringReader(html == null ? "" : html), new HTMLEditorKit.ParserCallback()
{
private int hidden;
private String href;
/*******************************************************************************
** Start readable block boundaries and remember link destinations.
*******************************************************************************/
@Override
public void handleStartTag(HTML.Tag tag, MutableAttributeSet attributes, int position)
{
if(tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT || tag == HTML.Tag.HEAD)
{
hidden++;
}
if(tag == HTML.Tag.A)
{
href = (String) attributes.getAttribute(HTML.Attribute.HREF);
}
if(tag == HTML.Tag.LI)
{
text.append("\n- ");
}
if(tag == HTML.Tag.TD || tag == HTML.Tag.TH)
{
text.append(" ");
}
}
/*******************************************************************************
** Add link destinations and block separators.
*******************************************************************************/
@Override
public void handleEndTag(HTML.Tag tag, int position)
{
if(tag == HTML.Tag.STYLE || tag == HTML.Tag.SCRIPT || tag == HTML.Tag.HEAD)
{
hidden = Math.max(0, hidden - 1);
}
if(tag == HTML.Tag.A && href != null)
{
text.append(" (").append(href).append(')');
href = null;
}
if(tag.isBlock())
{
text.append('\n');
}
}
/*******************************************************************************
** Line breaks remain line breaks.
*******************************************************************************/
@Override
public void handleSimpleTag(HTML.Tag tag, MutableAttributeSet attributes, int position)
{
if(tag == HTML.Tag.BR)
{
text.append('\n');
}
}
/*******************************************************************************
** Parser-decoded text, excluding executable and metadata content.
*******************************************************************************/
@Override
public void handleText(char[] data, int position)
{
if(hidden == 0)
{
text.append(data);
}
}
}, true);
}
catch(java.io.IOException e)
{
throw (new IllegalArgumentException("Could not convert email HTML"));
}
return (text.toString().replaceAll("[ \\t]+", " ").replaceAll(" *\\n *", "\n").replaceAll("\\n{3,}", "\n\n").strip());
}
}
@@ -1,117 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.support;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiStatus;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
/*******************************************************************************
** Internal adapter between the stable contract and the implementation.
*******************************************************************************/
public final class EmailApiMapper
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailApiMapper()
{
}
/*******************************************************************************
** Map every published request field without bypassing send validation.
*******************************************************************************/
public static EmailRequest request(EmailApiRequest request)
{
if(request == null)
{
return (null);
}
EmailRequest mapped = new EmailRequest()
.withTo(request.getTo())
.withCc(request.getCc())
.withBcc(request.getBcc())
.withFrom(request.getFrom())
.withFromAddressId(request.getFromAddressId())
.withReplyTo(request.getReplyTo())
.withSubject(request.getSubject())
.withMarkdownBody(request.getMarkdownBody())
.withHtmlBody(request.getHtmlBody())
.withTextBody(request.getTextBody())
.withProviderName(request.getProviderName())
.withTemplateName(request.getTemplateName())
.withBrandName(request.getBrandName())
.withLocale(request.getLocale())
.withIdempotencyKey(request.getIdempotencyKey())
.withTransaction(request.getTransaction(), request.getTransactionBackendName())
.withSendAfter(request.getSendAfter())
.withLinkedTableName(request.getLinkedTableName())
.withLinkedRecordId(request.getLinkedRecordId())
.withSenderQbit(request.getSenderQbit())
.withSenderProcess(request.getSenderProcess())
.withVariables(request.getVariables())
.withCategory(request.getCategory() == null ? null : EmailCategory.valueOf(request.getCategory().name()));
request.getRecordVariables().forEach((name, reference) -> mapped.withRecordVariable(name, reference.table(), reference.id()));
return (mapped);
}
/*******************************************************************************
** Map the complete create-once template contract.
*******************************************************************************/
public static EmailTemplateDefinition template(EmailApiTemplate template)
{
EmailTemplateDefinition mapped = new EmailTemplateDefinition()
.withName(template.getName())
.withLabel(template.getLabel())
.withKind(template.getKind())
.withSubject(template.getSubject())
.withBody(template.getBody())
.withTextBody(template.getTextBody())
.withVariablesSchema(template.getVariablesSchema())
.withSampleVariables(template.getSampleVariables())
.withOwnerQbit(template.getOwnerQbit())
.withEditPermissionName(template.getEditPermissionName())
.withCategory(template.getCategory() == null ? null : EmailCategory.valueOf(template.getCategory().name()));
template.getVariables().forEach(variable -> mapped.withVariable(variable.name(), variable.label(), variable.type(), variable.required(), variable.classification()));
return (mapped);
}
/*******************************************************************************
** Map enqueue outcomes by their stable names.
*******************************************************************************/
public static EmailApiResult result(EmailSendResult result)
{
return (new EmailApiResult(result.messageId(), EmailApiStatus.valueOf(result.status().name()), result.reasons()));
}
}
@@ -1,88 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.support;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Small database helpers shared by dispatcher and actions.
*******************************************************************************/
public final class EmailStore
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailStore()
{
}
/*******************************************************************************
** Equality filter.
*******************************************************************************/
public static QQueryFilter equal(String field, Serializable value)
{
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
}
/*******************************************************************************
** Read a row in a transaction.
*******************************************************************************/
public static QRecord get(String table, Integer id, QBackendTransaction transaction) throws QException
{
List<QRecord> rows = SystemWrites.query(table, equal("id", id), transaction);
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Check write errors without exposing record contents.
*******************************************************************************/
public static void update(String table, QRecord row, QBackendTransaction transaction) throws QException
{
List<QRecord> rows = SystemWrites.update(table, List.of(row), transaction);
if(rows.isEmpty() || (rows.get(0).getErrors() != null && !rows.get(0).getErrors().isEmpty()))
{
throw (new QException("Email database update failed"));
}
}
/*******************************************************************************
** Serialize provider rate checks and claims using the database row lock.
*******************************************************************************/
public static QRecord lockProvider(Integer id, QBackendTransaction transaction) throws QException
{
if(get(EmailTables.PROVIDER, id, transaction) == null)
{
return (null);
}
update(EmailTables.PROVIDER, new QRecord().withValue("id", id).withValue("dispatchLock", 0), transaction);
return (get(EmailTables.PROVIDER, id, transaction));
}
}
@@ -1,207 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.support;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
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.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The qbit's own reads and writes (the delivery engine, the outbox, health
** and rotation). They run in core's authorized scope, so they work with core
** permission enforcement on, and they mark the thread so the tables'
** customizers can tell a qbit write from a person's or the API's.
*******************************************************************************/
public final class SystemWrites
{
private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
/*******************************************************************************
** Work that may throw.
*******************************************************************************/
@FunctionalInterface
public interface Work<T>
{
/*******************************************************************************
** Do it.
*******************************************************************************/
T run() throws QException;
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SystemWrites()
{
}
/*******************************************************************************
** Run work as the qbit.
*******************************************************************************/
public static <T> T call(Work<T> work) throws QException
{
DEPTH.set(DEPTH.get() + 1);
try(CorePermissionEnforcer.AuthorizedScope ignored = CorePermissionEnforcer.openAuthorizedScope())
{
return (work.run());
}
finally
{
DEPTH.set(DEPTH.get() - 1);
}
}
/*******************************************************************************
** True while the qbit itself is writing on this thread.
*******************************************************************************/
public static boolean active()
{
return (DEPTH.get() > 0);
}
/*******************************************************************************
** Insert, returning the output (records may carry errors).
*******************************************************************************/
public static InsertOutput insert(String table, List<QRecord> records, QBackendTransaction transaction) throws QException
{
return (call(() -> new InsertAction().execute(new InsertInput(table).withRecords(records).withTransaction(transaction))));
}
/*******************************************************************************
** Insert one record, returning it (with errors if it failed).
*******************************************************************************/
public static QRecord insertOne(String table, QRecord record) throws QException
{
return (insert(table, List.of(record), null).getRecords().get(0));
}
/*******************************************************************************
** Update.
*******************************************************************************/
public static List<QRecord> update(String table, List<QRecord> records, QBackendTransaction transaction) throws QException
{
if(records.isEmpty())
{
return (records);
}
return (call(() -> new UpdateAction().execute(new UpdateInput(table).withRecords(records).withTransaction(transaction)).getRecords()));
}
/*******************************************************************************
** Update one record.
*******************************************************************************/
public static void updateOne(String table, QRecord record) throws QException
{
update(table, List.of(record), null);
}
/*******************************************************************************
** Query.
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
return (call(() -> new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false).withFilter(filter)).getRecords()));
}
/*******************************************************************************
** Query in a transaction.
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
{
return (call(() -> new QueryAction().execute(new QueryInput(table).withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false).withFilter(filter).withTransaction(transaction)).getRecords()));
}
/*******************************************************************************
** Count.
*******************************************************************************/
public static int count(String table, QQueryFilter filter) throws QException
{
return (count(table, filter, null));
}
/*******************************************************************************
** Count within the claim transaction so rate budgets remain shared.
*******************************************************************************/
public static int count(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
{
Integer count = call(() -> new CountAction().execute(new CountInput(table).withFilter(filter).withTransaction(transaction)).getCount());
return (count == null ? 0 : count);
}
/*******************************************************************************
** Delete by filter, returning the number deleted.
*******************************************************************************/
public static int delete(String table, QQueryFilter filter) throws QException
{
return (delete(table, filter, null));
}
/*******************************************************************************
** Delete within a transaction, including retention's parent and child rows.
*******************************************************************************/
public static int delete(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
{
Integer deleted = call(() -> new DeleteAction().execute(new DeleteInput(table).withQueryFilter(filter).withTransaction(transaction)).getDeletedRecordCount());
return (deleted == null ? 0 : deleted);
}
}
@@ -1,133 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/*******************************************************************************
** MJML stdin/stdout compiler with a total 20-second deadline and no files.
*******************************************************************************/
public class CliMjmlCompiler implements MjmlCompilerInterface
{
private final String binary;
/*******************************************************************************
** Explicit executable path; no shell interpolation or PATH guessing.
*******************************************************************************/
public CliMjmlCompiler(String binary)
{
this.binary = binary;
}
/*******************************************************************************
** Drain stdout concurrently with stdin, discarding potentially private stderr.
*******************************************************************************/
@Override
public String compile(String source)
{
rejectIncludes(source);
Process process = null;
try(var executor = Executors.newVirtualThreadPerTaskExecutor())
{
process = processBuilder().start();
Process running = process;
var task = executor.submit(() ->
{
var writer = executor.submit(() ->
{
try(var stdin = running.getOutputStream())
{
stdin.write(source.getBytes(StandardCharsets.UTF_8));
}
return (null);
});
byte[] output = running.getInputStream().readNBytes(10_000_001);
if(output.length > 10_000_000)
{
throw (new IllegalArgumentException("MJML output too large"));
}
writer.get();
if(running.waitFor() != 0)
{
throw (new IllegalArgumentException("MJML compilation failed"));
}
return (new String(output, StandardCharsets.UTF_8));
});
try
{
return (task.get(20, TimeUnit.SECONDS));
}
finally
{
running.descendants().forEach(ProcessHandle::destroyForcibly);
running.destroyForcibly();
task.cancel(true);
}
}
catch(Exception e)
{
if(e instanceof InterruptedException)
{
Thread.currentThread().interrupt();
}
throw (new IllegalArgumentException("MJML compilation failed or exceeded 20 seconds"));
}
finally
{
if(process != null)
{
process.destroyForcibly();
}
}
}
/*******************************************************************************
** MJML 4.x/5 supports allowIncludes=false. Refusing the source tag as well
** removes include resolution and its dependence on the inherited directory.
*******************************************************************************/
public static void rejectIncludes(String source)
{
if(source != null && java.util.regex.Pattern.compile("(?i)<\\s*mj-include\\b").matcher(source).find())
{
throw (new IllegalArgumentException("MJML includes are not allowed"));
}
}
/*******************************************************************************
** Package seam for checking child isolation without executing a compiler.
*******************************************************************************/
ProcessBuilder processBuilder()
{
ProcessBuilder builder = new ProcessBuilder(binary, "-i", "-s", "--config.allowIncludes=false");
String path = builder.environment().get("PATH");
builder.environment().clear();
if(path != null)
{
builder.environment().put("PATH", path);
}
return (builder.redirectError(ProcessBuilder.Redirect.DISCARD));
}
}
@@ -1,77 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Built-ins are created on first use, transactionally and without JVM state.
*******************************************************************************/
public final class EmailBuiltIns
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBuiltIns()
{
}
/*******************************************************************************
** Seed the secure-link pattern. The unique template name arbitrates replicas.
*******************************************************************************/
public static void ensure() throws QException
{
if(!SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "email_secure_message")).isEmpty())
{
return;
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
{
QRecord template = SystemWrites.insert(EmailTables.TEMPLATE, List.of(new QRecord().withValue("name", "email_secure_message")
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false)), tx).getRecords().getFirst();
if(template.getValue("id") == null || template.getErrors() != null && !template.getErrors().isEmpty())
{
tx.rollback();
return;
}
QRecord version = SystemWrites.insert(EmailTables.VERSION, List.of(new QRecord().withValue("templateId", template.getValue("id"))
.withValue("versionNumber", 1).withValue("status", "ACTIVE").withValue("locale", "en").withValue("subject", "New secure message")
.withValue("bodySource", "You have a new secure message from {{brand.label}}. Sign in to read it.\n\n[Read secure message]({{link}}){.button}")
.withValue("variablesSchema", "[{\"name\":\"link\",\"type\":\"URL\",\"required\":true}]")
.withValue("sampleVariables", "{\"link\":\"https://example.invalid/sign-in\"}")), tx).getRecords().getFirst();
if(version.getValue("id") == null || version.getErrors() != null && !version.getErrors().isEmpty())
{
throw (new QException("Could not install secure-message template"));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id")).withValue("activeVersionId", version.getValue("id")), tx);
tx.commit();
}
}
}
@@ -1,130 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.render.ContrastCheck;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
/*******************************************************************************
** Inline preview fallback: Next strips iframes and style blocks. Samples are
** sanitized server-side too; no live record references are read for previews.
*******************************************************************************/
public class EmailPreviewWidget extends AbstractWidgetRenderer
{
/*******************************************************************************
** Fetch the version and parent as the viewer, then render sample data.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
try
{
int id = Integer.parseInt(input.getQueryParams().get("id"));
List<QRecord> versions = new QueryAction().execute(new QueryInput(EmailTables.VERSION).withShouldFetchHeavyFields(true)
.withFilter(EmailStore.equal("id", id))).getRecords();
if(versions.isEmpty())
{
return (output("<p>Version unavailable.</p>"));
}
QRecord version = versions.getFirst();
List<QRecord> templates = new QueryAction().execute(new QueryInput(EmailTables.TEMPLATE)
.withFilter(EmailStore.equal("id", version.getValueInteger("templateId")))).getRecords();
if(templates.isEmpty())
{
return (output("<p>Template unavailable.</p>"));
}
QRecord template = templates.getFirst();
if("MJML".equals(template.getValueString("kind")) && version.getValueString("compiledHtml") == null)
{
if(!com.kingsrook.qbits.email.permissions.EmailPermissionChecker.has(com.kingsrook.qbits.email.permissions.EmailPermissions.MANAGE))
{
return (output("<p>Not compiled yet. Activate the version or use Preview as a manager.</p>"));
}
EmailVersions.validate(template, version);
}
EmailTemplateRenderer renderer = new EmailTemplateRenderer(template, version,
new EmailRequest().withVariables(VariableSchema.values(version.getValueString("sampleVariables"))));
QRecord rendered = renderer.render("preview", "https://example.invalid/unsubscribe/preview", false);
QRecord brand = EmailBrands.resolve(null, template);
String html = "<div style=\"min-width:0;overflow-wrap:anywhere\"><h3>" + EmailTemplateEngine.escape(rendered.getValueString("subject")) + "</h3>"
+ "<p>Sample preview. Inline rendering; mail clients may display it differently.</p>"
+ sample(rendered.getValueString("htmlBody"), brand, false, 390) + sample(rendered.getValueString("htmlBody"), brand, true, 600)
+ "<h4>Plain text</h4><pre style=\"white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%\">"
+ EmailTemplateEngine.escape(rendered.getValueString("textBody")) + "</pre></div>";
return (output(html + "<p>" + EmailTemplateEngine.escape(String.join("; ", renderer.warnings())) + "</p>"));
}
catch(IllegalArgumentException e)
{
return (output("<p>Preview unavailable: " + EmailTemplateEngine.escape(e.getMessage()) + "</p>"));
}
}
/*******************************************************************************
** Safe inline sample with bounded width and no executable source or CSS.
*******************************************************************************/
private static String sample(String html, QRecord brand, boolean dark, int width)
{
var document = Jsoup.parse(html);
document.select("head,script,style,iframe,form").remove();
document.select(".email-logo-" + (dark ? "light" : "dark")).remove();
String clean = Jsoup.clean(document.body().html(), Safelist.relaxed().addAttributes("td", "class"));
document = Jsoup.parseBodyFragment(clean);
document.select("table").forEach(e -> e.attr("style", "width:100%;table-layout:fixed;border-collapse:collapse"));
document.select("td,th").forEach(e -> e.attr("style", "overflow-wrap:anywhere;padding:6px"));
document.select("img").forEach(e -> e.attr("style", "max-width:100%;height:auto"));
document.select("pre").forEach(e -> e.attr("style", "white-space:pre-wrap;overflow-wrap:anywhere"));
document.select("a").forEach(e -> e.attr("style", "color:inherit;overflow-wrap:anywhere"));
document.select(".email-content").forEach(e -> e.attr("style", e.attr("style") + ";background-color:"
+ ContrastCheck.color(brand, dark ? "darkBackgroundColor" : "backgroundColor") + ";color:"
+ ContrastCheck.color(brand, dark ? "darkTextColor" : "textColor")));
document.select(".email-button").forEach(e -> e.attr("style", "padding:12px 20px;background-color:" + ContrastCheck.color(brand, dark ? "darkPrimaryColor" : "primaryColor")
+ ";color:" + ContrastCheck.color(brand, dark ? "darkButtonTextColor" : "buttonTextColor") + "!important"));
document.select(".email-button a").forEach(e -> e.attr("style", "color:" + ContrastCheck.color(brand, dark ? "darkButtonTextColor" : "buttonTextColor") + "!important;display:block;overflow-wrap:anywhere"));
document.select("[class]").forEach(e -> e.removeAttr("class"));
return ("<h4>" + (dark ? "Dark" : "Light") + " · " + width + "px maximum</h4><div style=\"box-sizing:border-box;width:100%;max-width:"
+ width + "px;padding:12px;overflow-wrap:anywhere;background-color:" + ContrastCheck.color(brand, dark ? "darkBackgroundColor" : "backgroundColor")
+ ";color:" + ContrastCheck.color(brand, dark ? "darkTextColor" : "textColor") + "\">" + document.body().html() + "</div>");
}
/*******************************************************************************
** Standard widget response.
*******************************************************************************/
private static RenderWidgetOutput output(String html)
{
return (new RenderWidgetOutput(new RawHTML(null, html)));
}
}
@@ -1,172 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Lifecycle and template edit permissions apply equally to forms and APIs.
*******************************************************************************/
public class EmailTemplateCustomizers implements TableCustomizerInterface
{
/*******************************************************************************
** Merge sparse updates and protect stored lifecycle state, not caller claims.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> rows, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
if(SystemWrites.active())
{
return (rows);
}
for(QRecord row : rows)
{
QRecord old = oldRecords.orElse(List.of()).stream().filter(v -> Objects.equals(v.getValue("id"), row.getValue("id"))).findFirst().orElse(new QRecord());
QRecord merged = new QRecord();
merged.getValues().putAll(old.getValues());
merged.getValues().putAll(row.getValues());
try
{
String table = input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput insert ? insert.getTableName()
: ((com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput) input).getTableName();
if(EmailTables.BRAND.equals(table))
{
EmailBrands.validate(merged);
}
else if(EmailTables.TEMPLATE.equals(table))
{
EmailVersions.requireEdit(old.getValue("id") == null ? merged : old, false);
protectedFields(row, old, List.of("phiApproved", "approvedVersionId", "phiApprovedBy", "phiApprovedDate", "activeVersionId"));
if(Boolean.TRUE.equals(old.getValueBoolean("phiApproved")))
{
protectedFields(row, old, List.of("kind", "category", "brandId", "name"));
}
}
else
{
QRecord parent = EmailStore.get(EmailTables.TEMPLATE, merged.getValueInteger("templateId"), null);
if(parent == null)
{
throw (new IllegalArgumentException("Template is required"));
}
EmailVersions.requireEdit(parent, false);
if(old.getValue("id") != null)
{
protectedFields(row, old, List.of("templateId", "versionNumber", "status", "compiledHtml", "activatedDate"));
immutable(old, parent);
}
else
{
if(merged.getValueString("status") != null && !"DRAFT".equals(merged.getValueString("status")))
{
throw (new IllegalArgumentException("New versions must be DRAFT"));
}
row.setValue("status", "DRAFT");
row.setValue("compiledHtml", null);
}
new VariableSchema(merged.getValueString("variablesSchema"));
}
}
catch(IllegalArgumentException e)
{
row.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (rows);
}
/*******************************************************************************
** Lifecycle-owned fields cannot be changed through ordinary CRUD.
*******************************************************************************/
private static void protectedFields(QRecord row, QRecord old, List<String> fields)
{
for(String field : fields)
{
Object value = row.getValue(field);
if(row.getValues().containsKey(field) && !Objects.equals(value, old.getValue(field)) && !(old.getValue("id") == null && Boolean.FALSE.equals(value)))
{
throw (new IllegalArgumentException("Use the version actions to change " + field));
}
}
}
/*******************************************************************************
** Approving a draft freezes its content, preventing approval substitution.
*******************************************************************************/
private static void immutable(QRecord version, QRecord template)
{
if(!"DRAFT".equals(version.getValueString("status")) || Objects.equals(version.getValue("id"), template.getValue("approvedVersionId")))
{
throw (new IllegalArgumentException("ACTIVE, RETIRED and PHI-approved versions cannot be edited; use New Version"));
}
}
/*******************************************************************************
** Preserve active/history rows and the same edit grants on deletion.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> rows, boolean preview) throws QException
{
if(SystemWrites.active())
{
return (rows);
}
for(QRecord row : rows)
{
try
{
if(EmailTables.VERSION.equals(input.getTableName()))
{
QRecord parent = EmailStore.get(EmailTables.TEMPLATE, row.getValueInteger("templateId"), input.getTransaction());
EmailVersions.requireEdit(parent, false);
immutable(row, parent);
}
else if(EmailTables.TEMPLATE.equals(input.getTableName()))
{
EmailVersions.requireEdit(row, false);
if(!SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", row.getValueInteger("id")), input.getTransaction()).isEmpty())
{
throw (new IllegalArgumentException("Templates with versions cannot be deleted"));
}
}
}
catch(IllegalArgumentException e)
{
row.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (rows);
}
}
@@ -1,315 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.email.outbox.EmailCategory;
/*******************************************************************************
** A template another qbit ships with its own code: its name, content and
** variable contract. Hand it to {@link EmailTemplates#ensure} at startup or on
** first use; it is created once as an ACTIVE version 1 and never overwritten.
*******************************************************************************/
public final class EmailTemplateDefinition
{
private String name;
private String label;
private EmailCategory category;
private String kind = "MARKDOWN";
private String subject;
private String body;
private String textBody;
private String variablesSchema;
private final List<Map<String, Object>> variables = new ArrayList<>();
private String sampleVariables;
private String ownerQbit;
private String editPermissionName;
/*******************************************************************************
** Unique snake_case template name, as used by EmailRequest.withTemplateName.
*******************************************************************************/
public EmailTemplateDefinition withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Human label shown in the template table.
*******************************************************************************/
public EmailTemplateDefinition withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Template category; it decides opt-out and PHI rules.
*******************************************************************************/
public EmailTemplateDefinition withCategory(EmailCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** MARKDOWN (the default), MJML or HTML.
*******************************************************************************/
public EmailTemplateDefinition withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Subject source, which may use variables.
*******************************************************************************/
public EmailTemplateDefinition withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Body source in the template's kind.
*******************************************************************************/
public EmailTemplateDefinition withBody(String body)
{
this.body = body;
return (this);
}
/*******************************************************************************
** Optional plain-text source; omitted, it is derived from the body.
*******************************************************************************/
public EmailTemplateDefinition withTextBody(String textBody)
{
this.textBody = textBody;
return (this);
}
/*******************************************************************************
** The variables schema as the JSON list the template table stores. Use this or
** withVariable, not both.
*******************************************************************************/
public EmailTemplateDefinition withVariablesSchema(String variablesSchema)
{
this.variablesSchema = variablesSchema;
return (this);
}
/*******************************************************************************
** Declare one variable (type: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL,
** LIST or RECORD; classification: PUBLIC, CONFIDENTIAL, PII, PHI or null).
*******************************************************************************/
public EmailTemplateDefinition withVariable(String name, String label, String type, boolean required, String classification)
{
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", name);
entry.put("label", label);
entry.put("type", type);
entry.put("required", required);
entry.put("classification", classification);
variables.add(entry);
return (this);
}
/*******************************************************************************
** Synthetic sample values as a JSON object, used to validate and preview.
*******************************************************************************/
public EmailTemplateDefinition withSampleVariables(String sampleVariables)
{
this.sampleVariables = sampleVariables;
return (this);
}
/*******************************************************************************
** The qbit that owns this template.
*******************************************************************************/
public EmailTemplateDefinition withOwnerQbit(String ownerQbit)
{
this.ownerQbit = ownerQbit;
return (this);
}
/*******************************************************************************
** Optional permission, besides the email permission, needed to edit the template.
*******************************************************************************/
public EmailTemplateDefinition withEditPermissionName(String editPermissionName)
{
this.editPermissionName = editPermissionName;
return (this);
}
/*******************************************************************************
** The template name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** The template label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** The template category.
*******************************************************************************/
public EmailCategory getCategory()
{
return (category);
}
/*******************************************************************************
** The template kind.
*******************************************************************************/
public String getKind()
{
return (kind);
}
/*******************************************************************************
** The subject source.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** The body source.
*******************************************************************************/
public String getBody()
{
return (body);
}
/*******************************************************************************
** The optional plain-text source.
*******************************************************************************/
public String getTextBody()
{
return (textBody);
}
/*******************************************************************************
** The schema JSON: the explicit text, or the typed variables as a JSON list.
*******************************************************************************/
public String getVariablesSchema()
{
if(variablesSchema != null && !variablesSchema.isBlank())
{
return (variablesSchema);
}
return (VariableSchema.json(variables));
}
/*******************************************************************************
** The sample values JSON, defaulting to an empty object.
*******************************************************************************/
public String getSampleVariables()
{
return (sampleVariables == null || sampleVariables.isBlank() ? "{}" : sampleVariables);
}
/*******************************************************************************
** The owning qbit.
*******************************************************************************/
public String getOwnerQbit()
{
return (ownerQbit);
}
/*******************************************************************************
** The optional edit permission.
*******************************************************************************/
public String getEditPermissionName()
{
return (editPermissionName);
}
/*******************************************************************************
** Plain-language reasons the definition is incomplete or malformed, before any
** rendering check. Empty when it passes.
*******************************************************************************/
public List<String> problems()
{
List<String> reasons = new ArrayList<>();
if(name == null || !name.matches("[a-z][a-z0-9_]{0,99}"))
{
reasons.add("Name must be snake_case: lowercase letters, digits and underscores, starting with a letter");
}
if(label == null || label.isBlank())
{
reasons.add("Label is required");
}
if(category == null)
{
reasons.add("Category is required");
}
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML").contains(kind))
{
reasons.add("Kind must be MARKDOWN, MJML or HTML");
}
if(subject == null || subject.isBlank())
{
reasons.add("Subject is required");
}
if(body == null || body.isBlank())
{
reasons.add("Body is required");
}
if(variablesSchema != null && !variablesSchema.isBlank() && !variables.isEmpty())
{
reasons.add("Declare variables with withVariablesSchema or withVariable, not both");
}
else
{
try
{
new VariableSchema(getVariablesSchema());
}
catch(IllegalArgumentException e)
{
reasons.add(e.getMessage());
}
}
try
{
VariableSchema.values(sampleVariables);
}
catch(IllegalArgumentException e)
{
reasons.add(e.getMessage());
}
return (reasons);
}
}
@@ -1,418 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/*******************************************************************************
** Small parsed template language: values, fallback, if and each, without code.
*******************************************************************************/
public class EmailTemplateEngine
{
private static final Pattern URL_ATTRIBUTE = Pattern.compile("(?i)(?:^|[\\s<])(href|src|action|background)\\s*=\\s*[\"']$");
private static final Pattern RENDERED_URL = Pattern.compile("(?is)\\b(href|src|action|background)(\\s*=\\s*)([\"'])(.*?)\\3");
/*******************************************************************************
** One budget shared by all parts and nested blocks of a render.
*******************************************************************************/
public static class Budget
{
private int iterations;
private int bytes;
/*******************************************************************************
** Count work before entering a loop body.
*******************************************************************************/
private void iterate()
{
if(++iterations > 10_000)
{
throw (new IllegalArgumentException("Template output is too large"));
}
}
/*******************************************************************************
** Bound UTF-8 output before appending it.
*******************************************************************************/
private void append(StringBuilder out, String value)
{
if(value.length() > 1_048_576 || (bytes += value.getBytes(StandardCharsets.UTF_8).length) > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
out.append(value);
}
}
/*******************************************************************************
** Rendering context. Markdown uses entities for all punctuation so values
** cannot inject Markdown links, images or block syntax.
*******************************************************************************/
public enum Context
{
HTML, TEXT, MARKDOWN
}
/*******************************************************************************
** A parsed literal, expression or block.
*******************************************************************************/
private record Node(String kind, String value, String fallback, int line, List<Node> children, String attribute)
{
}
/*******************************************************************************
** Validated immutable parse result.
*******************************************************************************/
public static class Parsed
{
private final List<Node> nodes;
private final Set<String> references;
/*******************************************************************************
** Store parsed nodes.
*******************************************************************************/
private Parsed(List<Node> nodes, Set<String> references)
{
this.nodes = nodes;
this.references = Set.copyOf(references);
}
/*******************************************************************************
** Render the already validated syntax tree.
*******************************************************************************/
public String render(Map<String, ?> values, Context context)
{
return (render(values, context, new Budget(), new ArrayList<>()));
}
/*******************************************************************************
** Bindings referenced by values and block conditions, including nested blocks.
*******************************************************************************/
public Set<String> references()
{
return (references);
}
/*******************************************************************************
** Render using the message-wide budget and safe author-facing warnings.
*******************************************************************************/
public String render(Map<String, ?> values, Context context, Budget budget, List<String> warnings)
{
StringBuilder out = new StringBuilder();
renderNodes(nodes, values, null, -1, context, budget, warnings, out);
return (context == Context.HTML ? validateRenderedUrls(out.toString(), warnings) : out.toString());
}
}
/*******************************************************************************
** Parse once, checking balanced blocks and every variable, with line numbers.
*******************************************************************************/
public Parsed parse(String source, VariableSchema schema)
{
source = source == null ? "" : source;
List<Node> root = new ArrayList<>();
Deque<Node> stack = new ArrayDeque<>();
Set<String> references = new LinkedHashSet<>();
int position = 0;
int line = 1;
while(position < source.length())
{
List<Node> current = stack.isEmpty() ? root : stack.peek().children();
int start = source.indexOf("{{", position);
if(start < 0)
{
current.add(new Node("literal", source.substring(position), null, 0, List.of(), null));
break;
}
current.add(new Node("literal", source.substring(position, start), null, 0, List.of(), null));
for(int i = position; i < start; i++)
{
if(source.charAt(i) == '\n')
{
line++;
}
}
int end = source.indexOf("}}", start + 2);
if(end < 0)
{
throw (error(line, "Unclosed expression"));
}
String expression = source.substring(start + 2, end).trim();
if(expression.startsWith("/"))
{
if(stack.isEmpty() || !stack.peek().kind().equals(expression.substring(1)))
{
throw (error(line, "Block nesting error"));
}
stack.pop();
}
else
{
String kind = expression.startsWith("#if ") ? "if" : expression.startsWith("#each ") ? "each" : "value";
String binding = kind.equals("value") ? expression : expression.substring(kind.length() + 2).trim();
String[] parts = binding.split("\\|", 2);
String name = parts[0].trim();
boolean inEach = stack.stream().anyMatch(n -> n.kind().equals("each"));
boolean local = name.equals("this") || name.startsWith("this.") || name.equals("@index");
if(!name.matches("(?:[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*|@index)")
|| (local ? !inEach : !schema.knows(name)) || !kind.equals("value") && parts.length > 1)
{
throw (error(line, "Unknown or unsupported variable " + name));
}
if(!local)
{
references.add(name);
}
var attribute = URL_ATTRIBUTE.matcher(source.substring(position, start));
Node node = new Node(kind, name, parts.length == 2 ? parts[1] : "", line, new ArrayList<>(), attribute.find() ? attribute.group(1).toLowerCase(java.util.Locale.ROOT) : null);
current.add(node);
if(!kind.equals("value"))
{
if(stack.size() >= 8)
{
throw (error(line, "Blocks exceed 8 levels"));
}
stack.push(node);
}
}
for(int i = start; i < end + 2; i++)
{
if(source.charAt(i) == '\n')
{
line++;
}
}
position = end + 2;
}
if(!stack.isEmpty())
{
throw (error(stack.peek().line(), "Unclosed " + stack.peek().kind() + " block"));
}
return (new Parsed(root, references));
}
/*******************************************************************************
** Render syntax nodes; loop scope is restored on every recursive return.
*******************************************************************************/
private static void renderNodes(List<Node> nodes, Map<String, ?> values, Object item, int index, Context context, Budget budget, List<String> warnings, StringBuilder out)
{
for(Node node : nodes)
{
Object value = node.value().equals("this") ? item : node.value().startsWith("this.") ? lookup(item, node.value().substring(5))
: node.value().equals("@index") ? index : lookup(values, node.value());
switch(node.kind())
{
case "literal" -> budget.append(out, node.value());
case "value" ->
{
String text = value == null || value.toString().isEmpty() ? node.fallback() : value.toString();
if(context == Context.HTML && node.attribute() != null && !text.isEmpty())
{
try
{
VariableSchema.url(text, "href".equals(node.attribute()));
}
catch(IllegalArgumentException e)
{
text = "";
String warning = "Unsafe URL omitted for " + node.value();
if(!warnings.contains(warning))
{
warnings.add(warning);
}
}
}
if(text.length() > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
budget.append(out, encode(text, context));
}
case "if" ->
{
if(truthy(value))
{
renderNodes(node.children(), values, item, index, context, budget, warnings, out);
}
}
case "each" ->
{
if(value instanceof List<?> list)
{
for(int i = 0; i < list.size(); i++)
{
budget.iterate();
renderNodes(node.children(), values, list.get(i), i, context, budget, warnings, out);
}
}
}
default -> throw (new IllegalStateException("Invalid template node"));
}
}
}
/*******************************************************************************
** Expressions in attributes require quotes, including after literal prefixes.
*******************************************************************************/
public static void validateHtml(String source)
{
boolean tag = false;
char quote = 0;
for(int i = 0; source != null && i < source.length(); i++)
{
char c = source.charAt(i);
if(tag && quote == 0 && source.startsWith("{{", i))
{
throw (new IllegalArgumentException("Template expressions in attributes must be quoted"));
}
if(quote != 0)
{
if(c == quote)
{
quote = 0;
}
}
else if(c == '<')
{
tag = true;
}
else if(c == '>')
{
tag = false;
}
else if(tag && (c == '\"' || c == '\''))
{
quote = c;
}
}
}
/*******************************************************************************
** Recheck complete attributes after block expansion, so whitespace, split
** values and conditional expressions cannot hide the URL context.
*******************************************************************************/
private static String validateRenderedUrls(String html, List<String> warnings)
{
var matcher = RENDERED_URL.matcher(html);
StringBuilder result = new StringBuilder();
while(matcher.find())
{
String value = org.jsoup.parser.Parser.unescapeEntities(matcher.group(4), true);
try
{
if(!value.isEmpty())
{
VariableSchema.url(value, "href".equalsIgnoreCase(matcher.group(1)));
}
matcher.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(matcher.group()));
}
catch(IllegalArgumentException e)
{
String warning = "Unsafe " + matcher.group(1).toLowerCase(java.util.Locale.ROOT) + " URL omitted";
if(!warnings.contains(warning))
{
warnings.add(warning);
}
matcher.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(matcher.group(1) + matcher.group(2) + matcher.group(3) + matcher.group(3)));
}
}
matcher.appendTail(result);
return (result.toString());
}
/*******************************************************************************
** Dotted paths traverse maps only; no reflection, methods or arbitrary code.
*******************************************************************************/
public static Object lookup(Object values, String path)
{
if(values instanceof Map<?, ?> map)
{
if(map.containsKey(path))
{
return (map.get(path));
}
int dot = path.indexOf('.');
return (dot < 0 ? map.get(path) : lookup(map.get(path.substring(0, dot)), path.substring(dot + 1)));
}
return (null);
}
/*******************************************************************************
** Explicit language truthiness.
*******************************************************************************/
public static boolean truthy(Object value)
{
if(value == null || Boolean.FALSE.equals(value) || value instanceof CharSequence s && s.isEmpty()
|| value instanceof Collection<?> c && c.isEmpty() || value instanceof Map<?, ?> m && m.isEmpty())
{
return (false);
}
return (!(value instanceof Number n) || new BigDecimal(n.toString()).compareTo(BigDecimal.ZERO) != 0);
}
/*******************************************************************************
** Escape values exactly once for the consuming grammar.
*******************************************************************************/
public static String encode(String value, Context context)
{
if(context == Context.TEXT)
{
return (value);
}
if(context == Context.MARKDOWN)
{
StringBuilder encoded = new StringBuilder();
value.codePoints().forEach(c -> encoded.append(Character.isLetterOrDigit(c) || c == ' ' ? new String(Character.toChars(c)) : "&#" + c + ";"));
return (encoded.toString());
}
return (escape(value));
}
/*******************************************************************************
** Attribute and text escaping, including both quote types.
*******************************************************************************/
public static String escape(Object value)
{
return (value == null ? "" : value.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Stable author-facing parse diagnostic.
*******************************************************************************/
private static IllegalArgumentException error(int line, String message)
{
return (new IllegalArgumentException("Line " + line + ": " + message));
}
}
@@ -1,453 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.phi.EmailBodyEncryption;
import com.kingsrook.qbits.email.phi.EmailClassifications;
import com.kingsrook.qbits.email.phi.EmailClassifications.Level;
import com.kingsrook.qbits.email.phi.EmailValueTokens;
import com.kingsrook.qbits.email.render.EmailBrands;
import com.kingsrook.qbits.email.render.EmailLayout;
import com.kingsrook.qbits.email.render.EmailMarkdown;
import com.kingsrook.qbits.email.render.HtmlToText;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Queue-time template resolution and rendering with sender-visible record data.
*******************************************************************************/
public final class EmailTemplateRenderer
{
private final QRecord template;
private final QRecord version;
private final QRecord brand;
private final VariableSchema schema;
private final Map<String, Serializable> values;
private final Map<String, EmailRequest.RecordVariable> records;
private final EmailCategory category;
private final boolean approved;
private final List<String> warnings = new ArrayList<>();
/*******************************************************************************
** Resolve a template's active locale, falling back only to English.
*******************************************************************************/
public static EmailTemplateRenderer prepare(EmailRequest request) throws QException
{
if("email_secure_message".equals(request.getTemplateName()))
{
EmailBuiltIns.ensure();
}
List<QRecord> templates = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", request.getTemplateName()));
if(templates.size() != 1)
{
throw (new IllegalArgumentException("Unknown email template"));
}
QRecord template = templates.getFirst();
List<QRecord> versions = SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", template.getValueInteger("id"))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
QRecord version = versions.stream().filter(v -> Objects.equals(request.getLocale(), v.getValueString("locale"))).findFirst()
.orElseGet(() -> versions.stream().filter(v -> "en".equals(v.getValueString("locale"))).findFirst().orElse(null));
if(version == null)
{
throw (new IllegalArgumentException("Template has no active version for this locale or en"));
}
return (new EmailTemplateRenderer(template, version, request));
}
/*******************************************************************************
** Validate schema, provenance and all template syntax before any outbox write.
*******************************************************************************/
public EmailTemplateRenderer(QRecord template, QRecord version, EmailRequest request) throws QException
{
this.template = template;
this.version = version;
approved = Boolean.TRUE.equals(template.getValueBoolean("phiApproved")) && version.getValue("id") != null
&& Objects.equals(template.getValue("approvedVersionId"), version.getValue("id"));
brand = EmailBrands.resolve(approved ? null : request.getBrandName(), template);
if(approved)
{
for(String field : List.of("logoUrl", "logoDarkUrl"))
{
if(brand.getValueString(field) != null && !EmailBrands.allowedImage(brand.getValueString(field), brand))
{
brand.setValue(field, null);
warnings.add("Brand logo omitted: image host is not allowed");
}
}
}
EmailBrands.validate(brand);
schema = new VariableSchema(version.getValueString("variablesSchema"));
records = new LinkedHashMap<>(request.getRecordVariables());
java.util.Set<String> references = new java.util.LinkedHashSet<>();
for(String field : List.of("subject", "preheader", "bodySource", "textSource", "compiledHtml"))
{
references.addAll(new EmailTemplateEngine().parse(version.getValueString(field), schema).references());
}
if(!"MARKDOWN".equals(template.getValueString("kind")))
{
EmailTemplateEngine.validateHtml(version.getValueString("bodySource"));
EmailTemplateEngine.validateHtml(version.getValueString("compiledHtml"));
}
Map<String, Serializable> supplied = new LinkedHashMap<>(request.getVariables());
for(var binding : records.entrySet())
{
supplied.keySet().removeIf(key -> key.startsWith(binding.getKey() + "."));
QTableMetaData table = QContext.getQInstance().getTable(binding.getValue().table());
if(table == null)
{
throw (new IllegalArgumentException("Record variable table is unavailable"));
}
java.util.Set<String> fields = references.stream().filter(path -> path.startsWith(binding.getKey() + "."))
.map(path -> path.substring(binding.getKey().length() + 1).split("\\.")[0]).collect(java.util.stream.Collectors.toSet());
if(fields.isEmpty())
{
supplied.put(binding.getKey(), new LinkedHashMap<>());
continue;
}
// Never use SystemWrites here: masking and record locks belong to the sender.
List<QRecord> rows = new QueryAction().execute(new QueryInput(table.getName()).withShouldFetchHeavyFields(true).withFieldNamesToInclude(fields)
.withFilter(EmailStore.equal(table.getPrimaryKeyField(), binding.getValue().id()).withLimit(1))).getRecords();
if(rows.isEmpty())
{
throw (new IllegalArgumentException("Record variable is unavailable to the sender"));
}
Map<String, Serializable> visible = new LinkedHashMap<>();
fields.forEach(field -> visible.put(field, rows.getFirst().getValue(field)));
for(var field : table.getFields().values())
{
var lock = field.getFieldSecurityLock();
if(lock != null && fields.contains(field.getName()))
{
var session = QContext.getQSession();
List<Serializable> keys = session == null ? List.of() : session.getSecurityKeyValues(lock.getSecurityKeyType());
boolean override = keys != null && lock.getOverrideValues() != null && keys.stream().anyMatch(lock.getOverrideValues()::contains);
boolean allowed = lock.getDefaultBehavior() == com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock.Behavior.ALLOW;
if(override ? allowed : !allowed)
{
visible.put(field.getName(), "************");
}
}
}
supplied.put(binding.getKey(), new LinkedHashMap<>(visible));
}
for(String name : supplied.keySet())
{
if(!schema.knows(name) || name.startsWith("brand.") || name.equals("message.id") || name.equals("now") || name.equals("unsubscribe_url"))
{
throw (new IllegalArgumentException("Unknown or reserved input variable " + name));
}
}
values = schema.validate(supplied);
category = EmailCategory.valueOf(Objects.requireNonNullElse(template.getValueString("category"), "TRANSACTIONAL"));
if(request.getCategory() != null && request.getCategory() != category)
{
throw (new IllegalArgumentException("Template category cannot be overridden"));
}
if(approved && category == EmailCategory.MARKETING)
{
throw (new IllegalArgumentException("PHI-approved templates cannot send MARKETING email"));
}
for(VariableSchema.Entry entry : schema.entries().values())
{
requireApproval(entry.name(), classification(entry.name()));
}
inspect(values, "");
for(String field : List.of("subject", "preheader", "bodySource", "textSource"))
{
new EmailTemplateEngine().parse(version.getValueString(field), schema);
}
if(version.getValueString("subject") == null || version.getValueString("bodySource") == null)
{
throw (new IllegalArgumentException("A template subject and body are required"));
}
}
/*******************************************************************************
** Check actual nested fields as well as declared but absent bindings.
*******************************************************************************/
private void inspect(Object object, String path)
{
if(!path.isEmpty())
{
requireApproval(path, classification(path));
}
if(object instanceof Map<?, ?> map)
{
map.forEach((key, value) -> inspect(value, path.isEmpty() ? key.toString() : path + "." + key));
}
else if(object instanceof List<?> list)
{
list.forEach(value -> inspect(value, path));
}
}
/*******************************************************************************
** A classification failure names only the binding, never its value.
*******************************************************************************/
private void requireApproval(String name, Level level)
{
if(EmailClassifications.sensitive(level) && !approved)
{
throw (new IllegalArgumentException("The variable " + name + " is " + level + "; this template is not approved for PHI."));
}
}
/*******************************************************************************
** Resolve record provenance before schema declarations; inherit parent levels.
*******************************************************************************/
private Level classification(String path)
{
VariableSchema.Entry declaration = schema.entries().get(path);
if(declaration == null)
{
declaration = schema.entries().values().stream().filter(e -> path.startsWith(e.name() + "."))
.max(java.util.Comparator.comparingInt(e -> e.name().length())).orElse(null);
}
String declared = declaration == null ? null : declaration.classification();
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + "."))
{
String field = path.substring(record.getKey().length() + 1).split("\\.")[0];
return (EmailClassifications.ofField(QContext.getQInstance().getTable(record.getValue().table()), field, declared, EmailRuntime.config()));
}
}
QTableMetaData table = declaration == null || declaration.table() == null ? null : QContext.getQInstance().getTable(declaration.table());
if(declaration != null && declaration.table() != null && (table == null || table.getField(declaration.field()) == null))
{
throw (new IllegalArgumentException("Schema field binding is unavailable"));
}
return (EmailClassifications.ofField(table, declaration == null ? null : declaration.field(), declared, EmailRuntime.config()));
}
/*******************************************************************************
** Validate the actual provider at queue and delivery time.
*******************************************************************************/
public void validateProvider(QRecord provider)
{
if(approved)
{
checkProvider(provider);
}
}
/*******************************************************************************
** Recheck mutable provider attestations before encrypted content leaves storage.
*******************************************************************************/
public static void checkProvider(QRecord provider)
{
if(!Boolean.TRUE.equals(provider.getValueBoolean("phiAllowed")) || Boolean.TRUE.equals(provider.getValueBoolean("trackOpens"))
|| Boolean.TRUE.equals(provider.getValueBoolean("trackClicks")))
{
throw (new IllegalArgumentException("PHI email needs a phi_allowed provider with open and click tracking disabled"));
}
}
/*******************************************************************************
** Render with the real message identity; no compiled source is rebuilt at send.
*******************************************************************************/
public QRecord render(String messageId, String unsubscribe, boolean encrypt)
{
Map<String, Serializable> context = new LinkedHashMap<>(values);
for(String field : List.of("label", "name", "siteUrl", "logoUrl", "logoDarkUrl", "logoAlt", "postalAddress", "primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "darkPrimaryColor", "darkButtonTextColor", "footerMarkdown"))
{
context.put("brand." + field.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT), brand.getValueString(field));
}
context.put("message.id", messageId);
context.put("now", EmailRuntime.clock().instant().toString());
context.put("unsubscribe_url", category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING ? unsubscribe : "");
EmailTemplateEngine engine = new EmailTemplateEngine();
EmailTemplateEngine.Budget budget = new EmailTemplateEngine.Budget();
String subject = engine.parse(version.getValueString("subject"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
if(subject.contains("\r") || subject.contains("\n"))
{
throw (new IllegalArgumentException("Subject must be one line"));
}
String kind = template.getValueString("kind");
String source = "MJML".equals(kind) ? version.getValueString("compiledHtml") : version.getValueString("bodySource");
if(source == null)
{
throw (new IllegalArgumentException("MJML version must be compiled before sending"));
}
String body = engine.parse(source, schema).render(context, "MARKDOWN".equals(kind) ? EmailTemplateEngine.Context.MARKDOWN : EmailTemplateEngine.Context.HTML, budget, warnings);
String preheader = engine.parse(version.getValueString("preheader"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
String html = "MARKDOWN".equals(kind) ? EmailLayout.render(EmailMarkdown.render(body, brand), preheader, brand, category, unsubscribe)
: EmailLayout.appendFooter(body, brand, category, unsubscribe);
if(approved)
{
html = restrictImages(html);
}
String text = version.getValueString("textSource") == null ? HtmlToText.convert(html)
: engine.parse(version.getValueString("textSource"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
if((long) subject.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + html.getBytes(java.nio.charset.StandardCharsets.UTF_8).length
+ text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1_048_576)
{
throw (new IllegalArgumentException("Template output is too large"));
}
return (new QRecord().withValue("subject", approved && encrypt ? EmailBodyEncryption.encrypt(subject, "subject", messageId) : subject)
.withValue("htmlBody", approved && encrypt ? EmailBodyEncryption.encrypt(html, "htmlBody", messageId) : html)
.withValue("textBody", approved && encrypt ? EmailBodyEncryption.encrypt(text, "textBody", messageId) : text)
.withValue("variablesSnapshot", VariableSchema.json(snapshot(values, "", messageId)))
.withValue("templateId", template.getValue("id")).withValue("templateVersionId", version.getValue("id"))
.withValue("brandId", brand.getValue("id")).withValue("locale", version.getValue("locale"))
.withValue("category", category.name()).withValue("phiApproved", approved));
}
/*******************************************************************************
** Recursive redaction prevents nested records or lists bypassing tokenization.
*******************************************************************************/
private Object snapshot(Object value, String path, String messageId)
{
if(!path.isEmpty() && (EmailClassifications.sensitive(classification(path)) || unclassifiedRecordField(path)))
{
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + ".") && !path.substring(record.getKey().length() + 1).contains("."))
{
QTableMetaData table = QContext.getQInstance().getTable(record.getValue().table());
var field = table.getField(path.substring(record.getKey().length() + 1));
if(field != null)
{
return (EmailValueTokens.forRecordField(table, field, record.getValue().id(), (Serializable) value));
}
}
}
return (EmailValueTokens.forContext(List.of("email", template.getValueString("name"), path, messageId), (Serializable) value));
}
if(value instanceof Map<?, ?> map)
{
Map<String, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(key.toString(), snapshot(item, path.isEmpty() ? key.toString() : path + "." + key, messageId)));
return (result);
}
if(value instanceof List<?> list)
{
List<Object> result = new ArrayList<>();
list.forEach(item -> result.add(snapshot(item, path, messageId)));
return (result);
}
return (value == null || value instanceof Number || value instanceof Boolean ? value : value.toString());
}
/*******************************************************************************
** Missing record classification fails closed only in the retained snapshot.
*******************************************************************************/
private boolean unclassifiedRecordField(String path)
{
for(var record : records.entrySet())
{
if(path.startsWith(record.getKey() + "."))
{
String field = path.substring(record.getKey().length() + 1).split("\\.")[0];
return (!EmailClassifications.hasSource(QContext.getQInstance().getTable(record.getValue().table()), field, EmailRuntime.config()));
}
}
return (false);
}
/*******************************************************************************
** Warnings contain binding names and policy reasons, never input values.
*******************************************************************************/
public List<String> warnings()
{
return (List.copyOf(new java.util.LinkedHashSet<>(warnings)));
}
/*******************************************************************************
** Apply the image policy after Markdown, layout and brand footer expansion.
*******************************************************************************/
private String restrictImages(String html)
{
var document = org.jsoup.Jsoup.parse(html);
document.outputSettings().prettyPrint(false);
document.select("[src],[background],[srcset],image").forEach(element ->
{
element.removeAttr("srcset");
if(element.hasAttr("src") && !EmailBrands.allowedImage(element.attr("src"), brand))
{
element.remove();
warnings.add("Image omitted: image host is not allowed");
}
if(element.hasAttr("background") && !EmailBrands.allowedImage(element.attr("background"), brand))
{
element.removeAttr("background");
warnings.add("Image omitted: image host is not allowed");
}
if("image".equals(element.normalName()))
{
element.remove();
warnings.add("SVG image omitted from approved email");
}
});
org.jsoup.select.NodeTraversor.filter((node, depth) ->
{
if(node instanceof org.jsoup.nodes.Comment comment && comment.getData().toLowerCase(java.util.Locale.ROOT).contains("[if"))
{
warnings.add("Conditional markup omitted from approved email");
return (org.jsoup.select.NodeFilter.FilterResult.REMOVE);
}
return (org.jsoup.select.NodeFilter.FilterResult.CONTINUE);
}, document);
// CSS image expressions can use escapes and image-set, so approved content
// uses img elements instead of CSS images. Layout colors remain intact.
document.select("[style]").forEach(element ->
{
String style = element.attr("style");
if(style.toLowerCase(java.util.Locale.ROOT).contains("url") || style.contains("\\") || style.toLowerCase(java.util.Locale.ROOT).contains("image-set"))
{
element.removeAttr("style");
warnings.add("CSS image omitted from approved email");
}
});
document.select("style").forEach(element ->
{
String style = element.data();
if(style.toLowerCase(java.util.Locale.ROOT).contains("url") || style.contains("\\") || style.toLowerCase(java.util.Locale.ROOT).contains("@import") || style.toLowerCase(java.util.Locale.ROOT).contains("image-set"))
{
element.remove();
warnings.add("CSS image omitted from approved email");
}
});
return (document.outerHtml());
}
}
@@ -1,118 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Entry point for other qbits to ship their templates. A template is created
** once; after that this class never touches it, so people's edits are safe.
*******************************************************************************/
public final class EmailTemplates
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTemplates()
{
}
/*******************************************************************************
** Create the template and an ACTIVE version 1 when no template of that name
** exists. The definition is validated like Activate Version (syntax, schema, a
** render with the samples, MJML compile); an invalid one is refused with
** plain reasons and nothing is written. Returns true only when this call
** created the template; the unique name arbitrates concurrent callers.
*******************************************************************************/
public static boolean ensure(EmailTemplateDefinition definition) throws QException
{
List<String> reasons = definition.problems();
if(!reasons.isEmpty())
{
throw (new IllegalArgumentException("Template definition refused: " + String.join("; ", reasons)));
}
return (SystemWrites.call(() -> create(definition)));
}
/*******************************************************************************
** The checked, transactional creation, run as the qbit.
*******************************************************************************/
private static boolean create(EmailTemplateDefinition definition) throws QException
{
if(exists(definition.getName()))
{
return (false);
}
QRecord template = new QRecord().withValue("name", definition.getName()).withValue("label", definition.getLabel())
.withValue("category", definition.getCategory().name()).withValue("kind", definition.getKind()).withValue("phiApproved", false)
.withValue("ownerQbit", definition.getOwnerQbit()).withValue("editPermissionName", definition.getEditPermissionName());
QRecord version = new QRecord().withValue("versionNumber", 1).withValue("status", "ACTIVE").withValue("locale", "en")
.withValue("subject", definition.getSubject()).withValue("bodySource", definition.getBody()).withValue("textSource", definition.getTextBody())
.withValue("variablesSchema", definition.getVariablesSchema()).withValue("sampleVariables", definition.getSampleVariables());
try
{
EmailVersions.validate(template, version);
}
catch(IllegalArgumentException e)
{
throw (new IllegalArgumentException("Template definition refused: " + e.getMessage()));
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
{
QRecord stored = SystemWrites.insert(EmailTables.TEMPLATE, List.of(template), tx).getRecords().getFirst();
if(stored.getValue("id") == null || stored.getErrors() != null && !stored.getErrors().isEmpty())
{
tx.rollback();
if(exists(definition.getName()))
{
return (false);
}
throw (new QException("Could not create template " + definition.getName()));
}
version.withValue("templateId", stored.getValue("id"));
QRecord storedVersion = SystemWrites.insert(EmailTables.VERSION, List.of(version), tx).getRecords().getFirst();
if(storedVersion.getValue("id") == null || storedVersion.getErrors() != null && !storedVersion.getErrors().isEmpty())
{
throw (new QException("Could not create template version for " + definition.getName()));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", stored.getValue("id")).withValue("activeVersionId", storedVersion.getValue("id")), tx);
tx.commit();
return (true);
}
}
/*******************************************************************************
** Whether a template of that name is already present.
*******************************************************************************/
private static boolean exists(String name) throws QException
{
return (!SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", name)).isEmpty());
}
}
@@ -1,68 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Human-readable version labels, resolving parent names as the current viewer.
*******************************************************************************/
public class EmailVersionLabels implements TableCustomizerInterface
{
/*******************************************************************************
** Batch parents to avoid one query per version, without bypassing security.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
var ids = records.stream().map(r -> r.getValueInteger("templateId")).filter(Objects::nonNull).distinct().toList();
Map<Integer, String> names = new HashMap<>();
if(!ids.isEmpty())
{
List<QRecord> templates = new QueryAction().execute(new QueryInput(EmailTables.TEMPLATE)
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids))
.withFieldNamesToInclude(new HashSet<>(List.of("id", "name")))).getRecords();
templates.forEach(t -> names.put(t.getValueInteger("id"), t.getValueString("name")));
}
for(QRecord row : records)
{
String locale = row.getValueString("locale");
row.setRecordLabel(names.getOrDefault(row.getValueInteger("templateId"), "Template") + " v" + row.getValueInteger("versionNumber")
+ (locale == null || locale.isBlank() || "en".equals(locale) ? "" : " (" + locale + ")"));
}
return (records);
}
}
@@ -1,215 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Atomic version lifecycle. Approval binds to exactly one immutable version.
*******************************************************************************/
public final class EmailVersions
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailVersions()
{
}
/*******************************************************************************
** Enforce both the qbit permission and the template's independent edit grant.
*******************************************************************************/
public static void requireEdit(QRecord template, boolean approval)
{
if(SystemWrites.active())
{
return;
}
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE))
{
throw (new IllegalArgumentException(approval ? "PHI approval needs email.admin" : "Editing templates needs email.manage"));
}
String permission = template.getValueString("editPermissionName");
if(permission != null && !permission.isBlank() && (QContext.getQSession() == null || !QContext.getQSession().hasPermission(permission)))
{
throw (new IllegalArgumentException("Editing this template needs " + permission));
}
}
/*******************************************************************************
** Serialize lifecycle operations across replicas using the template row.
*******************************************************************************/
private static QRecord lock(Integer templateId, QBackendTransaction tx) throws QException
{
QRecord template = EmailStore.get(EmailTables.TEMPLATE, templateId, tx);
if(template == null)
{
throw (new IllegalArgumentException("Template not found"));
}
requireEdit(template, false);
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", templateId).withValue("versionLock", 0), tx);
return (EmailStore.get(EmailTables.TEMPLATE, templateId, tx));
}
/*******************************************************************************
** Copy active (or latest) into a new draft, with a globally increasing number.
*******************************************************************************/
public static QRecord newVersion(Integer templateId) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(templateId, tx);
List<QRecord> versions = SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", templateId)
.withOrderBy(new QFilterOrderBy("versionNumber", false)), tx);
QRecord source = versions.stream().filter(v -> Objects.equals(template.getValue("activeVersionId"), v.getValue("id"))).findFirst()
.orElse(versions.isEmpty() ? new QRecord().withValue("locale", "en").withValue("variablesSchema", "[]").withValue("sampleVariables", "{}") : versions.getFirst());
QRecord draft = new QRecord();
for(String field : List.of("locale", "subject", "preheader", "bodySource", "textSource", "variablesSchema", "sampleVariables"))
{
draft.setValue(field, source.getValue(field));
}
draft.withValue("templateId", templateId).withValue("versionNumber", versions.isEmpty() ? 1 : versions.getFirst().getValueInteger("versionNumber") + 1)
.withValue("status", "DRAFT").withValue("createdBy", actor());
QRecord result = SystemWrites.insert(EmailTables.VERSION, List.of(draft), tx).getRecords().getFirst();
if(result.getValue("id") == null || result.getErrors() != null && !result.getErrors().isEmpty())
{
throw (new QException("Could not create template version"));
}
tx.commit();
return (result);
}
}
/*******************************************************************************
** Approval is granted to the selected draft or active version, never retired.
*******************************************************************************/
public static void approve(Integer versionId) throws QException
{
QRecord version = requiredVersion(versionId, null);
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(version.getValueInteger("templateId"), tx);
requireEdit(template, true);
version = requiredVersion(versionId, tx);
if("RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
{
throw (new IllegalArgumentException("Retired versions and MARKETING templates cannot be approved for PHI"));
}
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id"))
.withValue("phiApproved", true).withValue("approvedVersionId", versionId).withValue("phiApprovedBy", actor())
.withValue("phiApprovedDate", EmailRuntime.clock().instant()), tx);
tx.commit();
}
}
/*******************************************************************************
** Validate samples and syntax, compile MJML, then atomically retire and activate.
*******************************************************************************/
public static void activate(Integer versionId) throws QException
{
QRecord initial = requiredVersion(versionId, null);
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.TEMPLATE)))
{
QRecord template = lock(initial.getValueInteger("templateId"), tx);
QRecord version = requiredVersion(versionId, tx);
if(!"DRAFT".equals(version.getValueString("status")))
{
throw (new IllegalArgumentException("Only a DRAFT version can be activated"));
}
validate(template, version);
for(QRecord previous : SystemWrites.query(EmailTables.VERSION, EmailStore.equal("templateId", template.getValueInteger("id")), tx))
{
if("ACTIVE".equals(previous.getValueString("status")) && Objects.equals(version.getValue("locale"), previous.getValue("locale")))
{
EmailStore.update(EmailTables.VERSION, new QRecord().withValue("id", previous.getValue("id")).withValue("status", "RETIRED"), tx);
}
}
EmailStore.update(EmailTables.VERSION, new QRecord().withValue("id", versionId).withValue("status", "ACTIVE")
.withValue("compiledHtml", version.getValue("compiledHtml")).withValue("activatedDate", EmailRuntime.clock().instant()), tx);
QRecord change = new QRecord().withValue("id", template.getValue("id")).withValue("activeVersionId", versionId);
if(!Objects.equals(template.getValue("approvedVersionId"), versionId))
{
change.withValue("phiApproved", false).withValue("approvedVersionId", null).withValue("phiApprovedBy", null).withValue("phiApprovedDate", null);
}
EmailStore.update(EmailTables.TEMPLATE, change, tx);
tx.commit();
}
}
/*******************************************************************************
** Review validation is side-effect-free; compiled output stays in memory.
*******************************************************************************/
public static void validate(QRecord template, QRecord version) throws QException
{
requireEdit(template, false);
if("MJML".equals(template.getValueString("kind")))
{
CliMjmlCompiler.rejectIncludes(version.getValueString("bodySource"));
EmailTemplateEngine.validateHtml(version.getValueString("bodySource"));
if(EmailRuntime.config().getMjmlCompiler() == null)
{
throw (new IllegalArgumentException("MJML activation needs a configured compiler"));
}
new EmailTemplateEngine().parse(version.getValueString("bodySource"), new VariableSchema(version.getValueString("variablesSchema")));
version.setValue("compiledHtml", EmailRuntime.config().getMjmlCompiler().compile(version.getValueString("bodySource")));
}
EmailTemplateRenderer renderer = new EmailTemplateRenderer(template, version,
new EmailRequest().withVariables(VariableSchema.values(version.getValueString("sampleVariables"))));
renderer.render("preview", "https://example.invalid/unsubscribe/preview", false);
}
/*******************************************************************************
** Load a required version without exposing database diagnostics.
*******************************************************************************/
public static QRecord requiredVersion(Integer id, QBackendTransaction tx) throws QException
{
QRecord version = EmailStore.get(EmailTables.VERSION, id, tx);
if(version == null)
{
throw (new IllegalArgumentException("Template version not found"));
}
return (version);
}
/*******************************************************************************
** Stable session actor identity, with an explicit system fallback.
*******************************************************************************/
private static String actor()
{
return (QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference());
}
}
@@ -1,34 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
/*******************************************************************************
** Activation-time compilation only. Implementations must not persist sources.
*******************************************************************************/
@FunctionalInterface
public interface MjmlCompilerInterface
{
/*******************************************************************************
** Return HTML or a content-free failure.
*******************************************************************************/
String compile(String source);
}
@@ -1,212 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.template;
import java.io.Serializable;
import java.math.BigDecimal;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/*******************************************************************************
** Declarative variable contract. Validation errors never include input values.
*******************************************************************************/
public class VariableSchema
{
private final Map<String, Entry> entries = new LinkedHashMap<>();
private static final ObjectMapper JSON = new ObjectMapper();
/*******************************************************************************
** One schema binding, including optional record provenance.
*******************************************************************************/
public record Entry(String name, String label, String type, boolean required, String classification, String table, String field)
{
}
/*******************************************************************************
** Parse the JSON list and reject ambiguous or unsupported declarations.
*******************************************************************************/
public VariableSchema(String json)
{
try
{
List<Entry> parsed = JSON.readValue(json == null || json.isBlank() ? "[]" : json, new TypeReference<List<Entry>>() {});
for(Entry entry : parsed)
{
if(entry.name() == null || !entry.name().matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*")
|| !List.of("STRING", "NUMBER", "DATE", "DATE_TIME", "BOOLEAN", "URL", "LIST", "RECORD").contains(entry.type())
|| entries.putIfAbsent(entry.name(), entry) != null
|| entry.classification() != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(entry.classification())
|| (entry.table() == null) != (entry.field() == null))
{
throw (new IllegalArgumentException());
}
}
}
catch(Exception e)
{
throw (new IllegalArgumentException("Invalid variables schema"));
}
}
/*******************************************************************************
** Declared bindings in stable order.
*******************************************************************************/
public Map<String, Entry> entries()
{
return (Map.copyOf(entries));
}
/*******************************************************************************
** Built-ins and paths under declared records are known to the parser.
*******************************************************************************/
public boolean knows(String name)
{
if(name.equals("now") || name.equals("message.id") || name.equals("unsubscribe_url")
|| name.matches("brand\\.(label|name|site_url|logo_url|logo_dark_url|logo_alt|postal_address|primary_color|text_color|background_color|dark_background_color|dark_text_color|button_text_color|dark_primary_color|dark_button_text_color|footer_markdown)"))
{
return (true);
}
return (entries.containsKey(name) || entries.values().stream().anyMatch(e ->
("RECORD".equals(e.type()) || "LIST".equals(e.type())) && name.startsWith(e.name() + ".")));
}
/*******************************************************************************
** Validate and coerce declared values without discarding record structure.
*******************************************************************************/
public Map<String, Serializable> validate(Map<String, Serializable> values)
{
Map<String, Serializable> result = new LinkedHashMap<>(values);
for(Entry entry : entries.values())
{
Object value = EmailTemplateEngine.lookup(values, entry.name());
if(value == null || value instanceof String s && s.isEmpty())
{
if(entry.required())
{
throw (new IllegalArgumentException("Required variable " + entry.name() + " is missing"));
}
continue;
}
try
{
Serializable coerced = switch(entry.type())
{
case "STRING" -> value.toString();
case "NUMBER" -> new BigDecimal(value.toString());
case "DATE" -> LocalDate.parse(value.toString());
case "DATE_TIME" -> Instant.parse(value.toString());
case "BOOLEAN" -> booleanValue(value);
case "URL" -> url(value.toString());
case "LIST" -> (Serializable) (List<?>) value;
case "RECORD" -> (Serializable) (Map<?, ?>) value;
default -> throw (new IllegalArgumentException());
};
result.put(entry.name(), coerced);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Variable " + entry.name() + " must be " + entry.type()));
}
}
return (result);
}
/*******************************************************************************
** Strict boolean coercion avoids silently turning misspellings into false.
*******************************************************************************/
private static Boolean booleanValue(Object value)
{
if(!List.of("true", "false").contains(value.toString().toLowerCase(java.util.Locale.ROOT)))
{
throw (new IllegalArgumentException());
}
return (Boolean.valueOf(value.toString()));
}
/*******************************************************************************
** Only absolute web URLs are allowed in variable and brand URL fields.
*******************************************************************************/
public static String url(String value)
{
return (url(value, false));
}
/*******************************************************************************
** The same URL policy, optionally allowing mailto only for link destinations.
*******************************************************************************/
public static String url(String value, boolean allowMailto)
{
URI uri = URI.create(value);
if(allowMailto && "mailto".equalsIgnoreCase(uri.getScheme()) && !uri.getRawSchemeSpecificPart().isBlank()
&& !value.matches("(?is).*%(?:0a|0d).*"))
{
return (value);
}
if(uri.getScheme() == null || !List.of("http", "https").contains(uri.getScheme().toLowerCase(java.util.Locale.ROOT)) || uri.getHost() == null || uri.getUserInfo() != null)
{
throw (new IllegalArgumentException("URL must use http or https"));
}
return (value);
}
/*******************************************************************************
** Parse process inputs and sample values; never echo malformed JSON.
*******************************************************************************/
public static Map<String, Serializable> values(String json)
{
try
{
Map<String, Serializable> result = JSON.readValue(json == null || json.isBlank() ? "{}" : json, new TypeReference<LinkedHashMap<String, Serializable>>() {});
if(result == null)
{
throw (new IllegalArgumentException());
}
return (result);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Variables must be a JSON object"));
}
}
/*******************************************************************************
** Serialize safe snapshots using the existing Jackson dependency.
*******************************************************************************/
public static String json(Object value)
{
try
{
return (JSON.writeValueAsString(value));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Could not serialize email variables"));
}
}
}
@@ -1,302 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Provider payloads become small recipient-level events, never message bodies.
*******************************************************************************/
public final class EmailEventParser
{
/*******************************************************************************
** Provider identifiers remain scoped to the provider during database matching.
*******************************************************************************/
public record Event(String externalId, String messageId, String providerMessageId, String type, String bounceType,
String recipient, Instant occurred, String detail, String url)
{
}
/*******************************************************************************
** Parse one provider request, including all SendGrid events and SES recipients.
*******************************************************************************/
public List<Event> parse(String kind, String body, Instant received)
{
List<Event> events = new ArrayList<>();
switch(kind)
{
case "SES" -> ses(new JSONObject(body), received, events);
case "POSTMARK" -> postmark(new JSONObject(body), received, events);
case "MAILGUN" -> mailgun(new JSONObject(body).getJSONObject("event-data"), received, events);
case "SENDGRID" ->
{
JSONArray batch = new JSONArray(body);
for(int i = 0; i < batch.length(); i++)
{
sendgrid(batch.getJSONObject(i), received, events);
}
}
default -> throw new IllegalArgumentException("Unsupported email event kind");
}
return (events);
}
/*******************************************************************************
** SES recipients live under the specific event, with mail.destination fallback for non-suppressive events only.
*******************************************************************************/
private static void ses(JSONObject envelope, Instant received, List<Event> events)
{
if(!"Notification".equals(envelope.getString("Type")))
{
return;
}
JSONObject payload = new JSONObject(envelope.getString("Message"));
String rawType = payload.optString("eventType", payload.optString("notificationType"));
String type = switch(rawType)
{
case "Delivery" -> "DELIVERED";
case "Bounce" -> "BOUNCED";
case "Complaint" -> "COMPLAINED";
case "Reject" -> "REJECTED";
case "DeliveryDelay" -> "DEFERRED";
case "Open" -> "OPENED";
case "Click" -> "CLICKED";
default -> null;
};
if(type == null)
{
return;
}
JSONObject mail = payload.getJSONObject("mail");
String key = "DeliveryDelay".equals(rawType) ? "deliveryDelay" : rawType.toLowerCase(Locale.ROOT);
JSONObject data = object(payload, key);
String bounce = "BOUNCED".equals(type) ? switch(data.optString("bounceType"))
{
case "Permanent" -> "HARD";
case "Transient" -> "SOFT";
default -> "UNDETERMINED";
} : null;
String messageId = null;
JSONArray headers = mail.optJSONArray("headers");
if(headers != null)
{
for(int i = 0; i < headers.length(); i++)
{
JSONObject entry = headers.getJSONObject(i);
if("X-QQQ-Email-Id".equalsIgnoreCase(entry.optString("name")))
{
messageId = entry.optString("value", null);
}
}
}
JSONArray recipients = data.optJSONArray(switch(type)
{
case "BOUNCED" -> "bouncedRecipients";
case "COMPLAINED" -> "complainedRecipients";
case "DEFERRED" -> "delayedRecipients";
default -> "recipients";
});
if(recipients == null && ("BOUNCED".equals(type) || "COMPLAINED".equals(type)))
{
// Never widen a bounce or complaint to every destination; the event carries no recipient and is skipped.
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, null,
time(data.opt("timestamp"), received), null, null));
return;
}
if(recipients == null)
{
recipients = mail.getJSONArray("destination");
}
for(int i = 0; i < recipients.length(); i++)
{
Object recipient = recipients.get(i);
String address = recipient instanceof JSONObject row ? row.getString("emailAddress") : recipient.toString();
String reason = recipient instanceof JSONObject row ? row.optString("diagnosticCode", data.optString("bounceSubType", null))
: data.optString("reason", data.optString("smtpResponse", data.optString("delayType", data.optString("complaintFeedbackType", null))));
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, address,
time(data.opt("timestamp"), received), detail(reason), "CLICKED".equals(type) ? data.optString("link", null) : null));
}
}
/*******************************************************************************
** Postmark subscription reactivation is informational, never an unsubscribe.
*******************************************************************************/
private static void postmark(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("RecordType"))
{
case "Delivery" -> "DELIVERED";
case "Bounce" -> "BOUNCED";
case "SpamComplaint" -> "COMPLAINED";
case "Open" -> "OPENED";
case "Click" -> "CLICKED";
case "SubscriptionChange" -> data.optBoolean("SuppressSending", false) ? "UNSUBSCRIBED" : null;
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? switch(data.optString("Type"))
{
case "HardBounce", "SpamNotification", "SpamComplaint", "BadEmailAddress", "ManuallyDeactivated" -> "HARD";
case "SoftBounce", "Transient", "DnsError", "MailboxFull" -> "SOFT";
default -> "UNDETERMINED";
} : null;
Object date = data.opt("DeliveredAt");
for(String field : List.of("BouncedAt", "ReceivedAt", "ChangedAt"))
{
if(data.has(field))
{
date = data.get(field);
}
}
String id = data.has("ID") ? data.get("RecordType") + ":" + data.get("ID") : fingerprint(data);
events.add(new Event(id, null, data.optString("MessageID", null), type, bounce,
data.optString("Recipient", data.optString("Email", null)), time(date, received),
detail(data.optString("Description", data.optString("Details", data.optString("SuppressionReason", null)))),
"CLICKED".equals(type) ? data.optString("OriginalLink", null) : null));
}
/*******************************************************************************
** SendGrid retains the provider message id prefix before its delivery suffix.
*******************************************************************************/
private static void sendgrid(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("event"))
{
case "delivered" -> "DELIVERED";
case "bounce" -> "BOUNCED";
case "dropped" -> "REJECTED";
case "deferred" -> "DEFERRED";
case "spamreport" -> "COMPLAINED";
case "open" -> "OPENED";
case "click" -> "CLICKED";
case "unsubscribe", "group_unsubscribe" -> "UNSUBSCRIBED";
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? ("blocked".equals(data.optString("type")) ? "SOFT" : "HARD") : null;
String providerMessage = data.optString("sg_message_id", null);
if(providerMessage != null && providerMessage.contains("."))
{
providerMessage = providerMessage.substring(0, providerMessage.indexOf('.'));
}
events.add(new Event(data.optString("sg_event_id", fingerprint(data)),
data.optString("qqq_email_id", object(data, "custom_args").optString("qqq_email_id", null)), providerMessage, type, bounce,
data.optString("email", null), time(data.opt("timestamp"), received), detail(data.optString("reason", data.optString("response", null))),
"CLICKED".equals(type) ? data.optString("url", null) : null));
}
/*******************************************************************************
** Mailgun MIME ids are normalized identically to the outbound transport.
*******************************************************************************/
private static void mailgun(JSONObject data, Instant received, List<Event> events)
{
String type = switch(data.getString("event"))
{
case "delivered" -> "DELIVERED";
case "failed" -> "BOUNCED";
case "complained" -> "COMPLAINED";
case "opened" -> "OPENED";
case "clicked" -> "CLICKED";
case "unsubscribed" -> "UNSUBSCRIBED";
default -> null;
};
if(type == null)
{
return;
}
String bounce = "BOUNCED".equals(type) ? switch(data.optString("severity"))
{
case "permanent" -> "HARD";
case "temporary" -> "SOFT";
default -> "UNDETERMINED";
} : null;
JSONObject variables = object(data, "user-variables");
String providerMessage = object(object(data, "message"), "headers").optString("message-id", null);
if(providerMessage != null)
{
providerMessage = providerMessage.replaceAll("^<|>$", "");
}
events.add(new Event(data.optString("id", fingerprint(data)), variables.optString("qqq_email_id", variables.optString("v:qqq_email_id", null)),
providerMessage, type, bounce, data.optString("recipient", null), time(data.opt("timestamp"), received),
detail(data.optString("reason", object(data, "delivery-status").optString("message", null))), "CLICKED".equals(type) ? data.optString("url", null) : null));
}
/*******************************************************************************
** Optional nested objects contain no executable data.
*******************************************************************************/
private static JSONObject object(JSONObject parent, String key)
{
JSONObject value = parent.optJSONObject(key);
return (value == null ? new JSONObject() : value);
}
/*******************************************************************************
** Fall back to receipt time only when the provider supplied no timestamp.
*******************************************************************************/
private static Instant time(Object value, Instant fallback)
{
if(value == null || JSONObject.NULL.equals(value))
{
return (fallback);
}
if(value instanceof Number number)
{
return (Instant.ofEpochMilli((long) (number.doubleValue() * 1000)));
}
return (OffsetDateTime.parse(value.toString()).toInstant());
}
/*******************************************************************************
** Fallback identity for providers without a distinct event identifier.
*******************************************************************************/
private static String fingerprint(JSONObject data)
{
return (WebhookVerification.hash(data.toString().getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Mask complete addresses before truncation, including quoted local parts.
*******************************************************************************/
public static String detail(String reason)
{
if(reason == null)
{
return (null);
}
String masked = reason.replaceAll("(?i)(?:\"[^\"]*\"|[^\\s<>(),;:]+)@[^\\s<>(),;:]+", "[address]");
return (masked.substring(0, Math.min(500, masked.length())));
}
}
@@ -1,333 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.delivery.EmailAlerts;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.InternetAddress;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Bounded, replica-safe database transactions apply inbox rows exactly once.
** No network or external effects occur while the row's provider is locked.
*******************************************************************************/
public final class EmailInboxProcessor
{
private static final QLogger LOG = QLogger.getLogger(EmailInboxProcessor.class);
private static final int MAX_ATTEMPTS = 6;
/*******************************************************************************
** Poll due rows; transaction rollback releases claims after a worker crash.
*******************************************************************************/
public int runOnce() throws QException
{
int processed = 0;
for(QRecord row : SystemWrites.query(EmailTables.INBOX, EmailStore.equal("status", "PENDING")
.withCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())
.withOrderBy(new QFilterOrderBy("id")).withLimit(EmailRuntime.config().getBatchSize())))
{
if(process(row))
{
processed++;
}
}
return (processed);
}
/*******************************************************************************
** Re-read eligibility after the lock, including the observed attempt number.
*******************************************************************************/
private static boolean due(QRecord current, QRecord observed)
{
return (current != null && "PENDING".equals(current.getValueString("status"))
&& Objects.equals(current.getValue("attempts"), observed.getValue("attempts"))
&& current.getValueInstant("nextAttemptDate") != null && !current.getValueInstant("nextAttemptDate").isAfter(EmailRuntime.clock().instant()));
}
/*******************************************************************************
** All event, message, suppression and receipt changes commit together.
*******************************************************************************/
private boolean process(QRecord observed) throws QException
{
boolean complaint = false;
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
QRecord provider = EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
if(!due(row, observed))
{
return (false);
}
int applied = 0;
int unmatched = 0;
int skipped = 0;
for(EmailEventParser.Event event : new EmailEventParser().parse(row.getValueString("kind"), row.getValueString("payloadJson"), row.getValueInstant("receivedDate")))
{
if(("OPENED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackOpens")))
|| ("CLICKED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackClicks"))))
{
continue;
}
QRecord message = match(event, provider.getValueInteger("id"), tx);
if(message == null || "SENDING".equals(message.getValueString("status")))
{
unmatched++;
continue;
}
String recipient = normalize(event.recipient());
if(recipient == null || !recipients(message).contains(recipient))
{
LOG.info("Ignoring an email event whose recipient is not on the message", logPair("messageId", message.getValue("id")));
skipped++;
continue;
}
if(Boolean.TRUE.equals(message.getValueBoolean("phiApproved")) && List.of("OPENED", "CLICKED").contains(event.type()))
{
continue;
}
String key = provider.getValueInteger("id") + ":" + WebhookVerification.hash((event.externalId() + "\n" + event.type() + "\n"
+ recipient).getBytes(StandardCharsets.UTF_8));
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
if(stored == null)
{
continue;
}
applied++;
updateMessage(message, stored, tx);
if("COMPLAINED".equals(event.type()) || ("BOUNCED".equals(event.type()) && "HARD".equals(event.bounceType())))
{
EmailTrackingStore.suppress(message, stored, "ALL", "COMPLAINED".equals(event.type()) ? "COMPLAINT" : "HARD_BOUNCE", tx);
complaint |= "COMPLAINED".equals(event.type());
}
else if("UNSUBSCRIBED".equals(event.type()))
{
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
}
}
int attempts = row.getValueInteger("attempts") + 1;
boolean retry = unmatched > 0 && attempts < MAX_ATTEMPTS;
List<String> notes = new ArrayList<>();
if(unmatched > 0)
{
notes.add(unmatched + (unmatched == 1 ? " event did not match a message" : " events did not match a message"));
}
if(skipped > 0)
{
notes.add(skipped + (skipped == 1 ? " event was skipped (recipient not on the message)" : " events were skipped (recipient not on the message)"));
}
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id"))
.withValue("status", retry ? "PENDING" : (applied == 0 && unmatched == 0 ? "IGNORED" : "PROCESSED")).withValue("attempts", attempts)
.withValue("processedDate", retry ? null : EmailRuntime.clock().instant())
.withValue("nextAttemptDate", retry ? EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts)) : null)
.withValue("lastError", notes.isEmpty() ? null : String.join("; ", notes)), tx);
tx.commit();
}
catch(Exception e)
{
fail(observed);
return (false);
}
if(complaint)
{
complaintAlert();
}
return (true);
}
/*******************************************************************************
** Canonical mailbox, or null when the provider supplied none that is valid.
*******************************************************************************/
private static String normalize(String value)
{
if(value == null || value.isBlank())
{
return (null);
}
try
{
return (EmailTrackingStore.address(value));
}
catch(IllegalArgumentException e)
{
return (null);
}
}
/*******************************************************************************
** The canonical to, cc and bcc mailboxes of a message; only these may be
** suppressed by an event matched to it.
*******************************************************************************/
private static Set<String> recipients(QRecord message)
{
Set<String> set = new HashSet<>();
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
{
String value = message.getValueString(field);
if(value == null || value.isBlank())
{
continue;
}
try
{
for(InternetAddress address : InternetAddress.parse(value, false))
{
set.add(address.getAddress().toLowerCase(Locale.ROOT));
}
}
catch(jakarta.mail.internet.AddressException e)
{
// A malformed stored envelope contributes no recipients.
}
}
return (set);
}
/*******************************************************************************
** Match our id first, then the provider id; neither may cross providers.
*******************************************************************************/
private static QRecord match(EmailEventParser.Event event, Integer providerId, QBackendTransaction tx) throws QException
{
if(event.messageId() != null && event.messageId().matches("[0-9]{1,10}") && Long.parseLong(event.messageId()) <= Integer.MAX_VALUE)
{
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(event.messageId()), tx);
if(message != null && providerId.equals(message.getValueInteger("providerId")))
{
return (message);
}
}
if(event.providerMessageId() == null || event.providerMessageId().isBlank())
{
return (null);
}
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("providerId", providerId)
.withCriteria("providerMessageId", QCriteriaOperator.EQUALS, event.providerMessageId()), tx);
return (rows.size() == 1 ? rows.getFirst() : null);
}
/*******************************************************************************
** Complaint outranks hard bounce, which outranks delivery; never regress.
*******************************************************************************/
private static void updateMessage(QRecord message, QRecord event, QBackendTransaction tx) throws QException
{
String type = event.getValueString("eventType");
String status = message.getValueString("status");
String field = switch(type)
{
case "DELIVERED" -> "SENT".equals(status) ? "deliveredDate" : null;
case "BOUNCED" -> "HARD".equals(event.getValueString("bounceType")) && List.of("SENT", "DELIVERED").contains(status) ? "bouncedDate" : null;
case "COMPLAINED" -> List.of("SENT", "DELIVERED", "BOUNCED").contains(status) ? "complainedDate" : null;
default -> null;
};
if(field != null)
{
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("status", type)
.withValue(field, event.getValue("occurredDate")), tx);
}
}
/*******************************************************************************
** After rollback, only the still-due observed attempt may consume retry budget.
*******************************************************************************/
private static void fail(QRecord observed) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
if(due(row, observed))
{
int attempts = row.getValueInteger("attempts") + 1;
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts)
.withValue("status", attempts >= MAX_ATTEMPTS ? "FAILED" : "PENDING").withValue("lastError", "Email event processing failed; check message matching and payload validity.")
.withValue("nextAttemptDate", attempts >= MAX_ATTEMPTS ? null : EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts))), tx);
tx.commit();
}
}
}
/*******************************************************************************
** Standard Reprocess action rechecks FAILED under the same transaction lock.
*******************************************************************************/
public static boolean reprocess(Integer id) throws QException
{
QRecord selected = EmailStore.get(EmailTables.INBOX, id, null);
if(selected == null)
{
return (false);
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(selected.getValueInteger("providerId"), tx);
QRecord row = EmailStore.get(EmailTables.INBOX, id, tx);
if(!"FAILED".equals(row.getValueString("status")))
{
return (false);
}
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", id).withValue("status", "PENDING").withValue("attempts", 0)
.withValue("nextAttemptDate", EmailRuntime.clock().instant()).withValue("lastError", null).withValue("processedDate", null), tx);
tx.commit();
return (true);
}
}
/*******************************************************************************
** Optional alerting cannot turn a committed event into a failed inbox row.
*******************************************************************************/
private static void complaintAlert()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.alerts.QAlerts");
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventType", "COMPLAINED")
.withCriteria("occurredDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, EmailRuntime.clock().instant().minusSeconds(86400))
.withCriteria("occurredDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())) > 3)
{
EmailAlerts.raise(EmailAlerts.COMPLAINT, 0);
}
}
catch(Exception | LinkageError e)
{
// Alert integration is optional; event data is already committed.
}
}
}
@@ -1,218 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.InternetAddress;
/*******************************************************************************
** DB-only receipt, event deduplication and suppression writes.
*******************************************************************************/
public final class EmailTrackingStore
{
/*******************************************************************************
** Distinguish an authenticated retry from reuse of a Mailgun nonce.
*******************************************************************************/
public enum Receipt
{
RECEIVED, DUPLICATE, REPLAY
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailTrackingStore()
{
}
/*******************************************************************************
** Serialize address upserts across providers, then acquire the dispatcher lock.
** Every tracking writer uses this order and does no network I/O while locked.
*******************************************************************************/
public static QRecord lock(Integer providerId, QBackendTransaction tx) throws QException
{
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(1), tx);
if(providers.isEmpty())
{
throw (new QException("Email provider unavailable"));
}
EmailStore.lockProvider(providers.getFirst().getValueInteger("id"), tx);
QRecord provider = EmailStore.lockProvider(providerId, tx);
if(provider == null)
{
throw (new QException("Email provider unavailable"));
}
return (provider);
}
/*******************************************************************************
** A provider lock avoids expected duplicate SQL errors (and payload logging).
** Unique constraints remain the final database guard.
*******************************************************************************/
public static Receipt receive(QRecord provider, String externalId, String nonce, byte[] bytes, String note) throws QException
{
Integer providerId = provider.getValueInteger("id");
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailStore.lockProvider(providerId, tx);
QQueryFilter filter = EmailStore.equal("providerId", providerId);
if(nonce != null && SystemWrites.count(EmailTables.INBOX, filter.clone().withCriteria("nonce", QCriteriaOperator.EQUALS, nonce), tx) > 0)
{
return (Receipt.REPLAY);
}
if(SystemWrites.count(EmailTables.INBOX, filter.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId), tx) > 0)
{
return (Receipt.DUPLICATE);
}
var now = EmailRuntime.clock().instant();
QRecord row = new QRecord().withValue("providerId", providerId).withValue("kind", provider.getValue("kind"))
.withValue("externalEventId", externalId).withValue("nonce", nonce).withValue("status", note == null ? "PENDING" : "IGNORED")
.withValue("attempts", 0).withValue("nextAttemptDate", note == null ? now : null).withValue("lastError", note)
.withValue("payloadJson", new String(bytes, StandardCharsets.UTF_8)).withValue("payloadSha256", WebhookVerification.hash(bytes))
.withValue("receivedDate", now).withValue("processedDate", note == null ? null : now);
copySecurity(provider, row);
insert(EmailTables.INBOX, row, tx);
tx.commit();
return (Receipt.RECEIVED);
}
catch(QException e)
{
// Re-query after rollback: a unique violation aborts a PostgreSQL transaction.
if(nonce != null && SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
.withCriteria("nonce", QCriteriaOperator.EQUALS, nonce)) > 0)
{
return (Receipt.REPLAY);
}
if(SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId)) > 0)
{
return (Receipt.DUPLICATE);
}
throw (new QException("Email inbox write failed"));
}
}
/*******************************************************************************
** Fail atomically without exposing input values or database diagnostics.
*******************************************************************************/
public static QRecord insert(String table, QRecord row, QBackendTransaction tx) throws QException
{
List<QRecord> records = SystemWrites.insert(table, List.of(row), tx).getRecords();
if(records.isEmpty() || records.getFirst().getValueInteger("id") == null || (records.getFirst().getErrors() != null && !records.getFirst().getErrors().isEmpty()))
{
throw (new QException("Email tracking write failed"));
}
return (records.getFirst());
}
/*******************************************************************************
** Preserve host tenant security on child and suppression records.
*******************************************************************************/
public static void copySecurity(QRecord source, QRecord target)
{
for(var field : EmailRuntime.config().getSecurityFields())
{
target.withValue(field.getName(), source.getValue(field.getName()));
}
}
/*******************************************************************************
** Insert a recipient event once even across overlapping provider batches.
*******************************************************************************/
public static QRecord event(QRecord message, EmailEventParser.Event event, String key, QBackendTransaction tx) throws QException
{
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx) > 0)
{
return (null);
}
QRecord row = new QRecord().withValue("messageId", message.getValue("id")).withValue("providerId", message.getValue("providerId"))
.withValue("eventType", event.type()).withValue("bounceType", event.bounceType()).withValue("recipient", address(event.recipient()))
.withValue("occurredDate", event.occurred()).withValue("detail", event.detail()).withValue("url", "CLICKED".equals(event.type()) ? event.url() : null)
.withValue("eventKey", key);
copySecurity(message, row);
return (insert(EmailTables.EVENT, row, tx));
}
/*******************************************************************************
** Upsert without weakening a complaint or shortening an existing suppression.
** All callers hold the common tracking lock throughout their transaction.
*******************************************************************************/
public static void suppress(QRecord message, QRecord event, String scope, String reason, QBackendTransaction tx) throws QException
{
if("UNSUBSCRIBE".equals(reason) && !List.of("MARKETING", "NOTIFICATION").contains(scope == null ? "" : scope))
{
return;
}
String address = event.getValueString("recipient");
List<QRecord> existing = SystemWrites.query(EmailTables.SUPPRESSION, EmailStore.equal("address", address)
.withCriteria("scope", QCriteriaOperator.EQUALS, scope), tx);
QRecord row = new QRecord().withValue("address", address).withValue("scope", scope).withValue("reason", reason)
.withValue("sourceMessageId", message.getValue("id")).withValue("sourceEventId", event.getValue("id")).withValue("expiresDate", null);
copySecurity(message, row);
if(existing.isEmpty())
{
row.withValue("createdDate", EmailRuntime.clock().instant());
insert(EmailTables.SUPPRESSION, row, tx);
}
else
{
QRecord old = existing.getFirst();
if("COMPLAINT".equals(old.getValueString("reason")))
{
row.withValue("reason", "COMPLAINT");
}
row.withValue("id", old.getValue("id"));
EmailStore.update(EmailTables.SUPPRESSION, row, tx);
}
}
/*******************************************************************************
** Canonical mailbox for event deduplication and suppression lookup.
*******************************************************************************/
public static String address(String value)
{
try
{
InternetAddress address = new InternetAddress(value, true);
address.validate();
return (address.getAddress().toLowerCase(Locale.ROOT));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Email event recipient is invalid"));
}
}
}
@@ -1,132 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.tracking;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
import com.kingsrook.qbits.email.support.EmailStore;
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Stateless token validation and atomic, idempotent category opt-out.
*******************************************************************************/
public final class EmailUnsubscribe
{
/*******************************************************************************
** Resolve the signed message and its single recipient, without writes.
*******************************************************************************/
public QRecord resolve(String token) throws QException
{
return (resolve(token, null));
}
/*******************************************************************************
** Recheck the binding against the same transaction used for suppression.
*******************************************************************************/
private QRecord resolve(String token, QBackendTransaction transaction) throws QException
{
JSONObject payload = EmailUnsubscribeLinks.verify(token);
if(payload == null)
{
return (null);
}
try
{
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(payload.getString("messageId")), transaction);
if(message == null || !payload.getString("category").equals(message.getValueString("category")))
{
return (null);
}
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
String hash = payload.getString("addressHash");
// Safe legacy links remain valid only after the single-mailbox check above.
String legacyEnvelope = message.getValueString("toAddresses") + "," + message.getValueString("ccAddresses") + "," + message.getValueString("bccAddresses");
boolean matches = WebhookVerification.equal(hash, WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8)))
|| WebhookVerification.equal(hash, WebhookVerification.hash(legacyEnvelope.getBytes(StandardCharsets.UTF_8)));
return (matches ? message : null);
}
catch(IllegalArgumentException | org.json.JSONException e)
{
return (null);
}
}
/*******************************************************************************
** Opt out only the signed mailbox; reject changed or multi-recipient envelopes.
** Never display those addresses on the public page or emit them to logs.
*******************************************************************************/
public void unsubscribe(String token) throws QException
{
QRecord initial = resolve(token);
if(initial == null)
{
throw (new IllegalArgumentException("Invalid unsubscribe link"));
}
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
{
EmailTrackingStore.lock(initial.getValueInteger("providerId"), tx);
QRecord message = resolve(token, tx);
if(message == null || !initial.getValueInteger("providerId").equals(message.getValueInteger("providerId")))
{
throw (new IllegalArgumentException("Invalid unsubscribe link"));
}
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
String key = "unsubscribe:" + message.getValueInteger("id") + ":" + WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8));
var event = new EmailEventParser.Event(key, message.getValueString("id"), null, "UNSUBSCRIBED", null, recipient, EmailRuntime.clock().instant(), null, null);
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
if(stored == null)
{
stored = com.kingsrook.qbits.email.support.SystemWrites.query(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx).getFirst();
}
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
tx.commit();
}
}
/*******************************************************************************
** Self-contained accessible page; an empty form action posts to this same URL.
*******************************************************************************/
public String page(QRecord message, boolean confirmed) throws QException
{
QRecord brand = message.getValueInteger("brandId") == null ? null : EmailStore.get(EmailTables.BRAND, message.getValueInteger("brandId"), null);
String label = brand == null || brand.getValueString("label") == null ? EmailRuntime.config().getAppLabel() : brand.getValueString("label");
String category = "MARKETING".equals(message.getValueString("category")) ? "Marketing" : "Notification";
String question = confirmed ? "You are unsubscribed from " + category + " email from " + label + "."
: "Unsubscribe from " + category + " email from " + label + "?";
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<meta name=\"color-scheme\" content=\"light dark\"><title>Email preferences</title><style>"
+ ":root{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;padding:20px;font:18px/1.5 system-ui;background:#fff;color:#172033}"
+ "main{max-width:560px;margin:40px auto;overflow-wrap:anywhere}h1{font-size:1.5rem}button{max-width:100%;padding:12px 20px;font:inherit;background:#174ea6;color:#fff;border:0;border-radius:6px;cursor:pointer}"
+ "@media(prefers-color-scheme:dark){body{background:#111827;color:#f9fafb}button{background:#a9c7ff;color:#111827}}</style></head><body><main><p>"
+ EmailTemplateEngine.escape(label) + "</p><h1>" + EmailTemplateEngine.escape(question) + "</h1>"
+ (confirmed ? "" : "<form method=\"post\"><input type=\"hidden\" name=\"confirm\" value=\"unsubscribe\"><button type=\"submit\">Unsubscribe</button></form>")
+ "</main></body></html>");
}
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more