Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9767cef03c
|
||
|
|
f93480b521 | ||
|
|
2bbe208421 | ||
|
|
bd2724b384 | ||
|
|
64cb2448cf | ||
|
|
2fc7853ec1
|
||
|
|
faad539c60 | ||
|
|
e789f5bd83
|
||
|
|
755a1e2465 | ||
|
|
0c3c1564c3
|
||
|
|
68137d6fff | ||
|
|
ba033f90bc
|
||
|
|
33b70be266 | ||
|
|
d11e38139c
|
||
|
|
a9415f93d9 | ||
|
|
50f31a9040
|
||
|
|
640e37f7c2 | ||
|
|
0cd1ee0c09 | ||
|
|
b3e3b87899 | ||
|
|
5b21576a0b | ||
|
|
156ca565fc
|
||
|
|
7cfafd7d2f | ||
|
|
e88216de7e
|
||
|
|
0d326948fa | ||
|
|
6dd1a00fbb
|
||
|
|
388f3eeade | ||
|
|
534d61c19a
|
||
|
|
28d0178a66 | ||
|
|
21d754d68c
|
||
|
|
fb31d1e542
|
||
|
|
6e9351e8e3
|
||
|
|
ccb256769c | ||
|
|
2a2d69c5f6
|
||
|
|
877b6eb6fb
|
||
|
|
0122fb9fc1
|
||
|
|
fea8f8d445
|
||
|
|
5be0e68ebb
|
||
|
|
303848a86b | ||
|
|
3168b1979b | ||
|
|
28b964ab17
|
||
|
|
30352cf7c5 | ||
|
|
d2bddc968a | ||
|
|
acff558a9a
|
||
|
|
a3ea38eb7e
|
||
|
|
6eb5854fb4 | ||
|
|
dc23fbdae7
|
||
|
|
5f6b661d1a
|
||
|
|
40f6935c19
|
||
|
|
351a8070db | ||
|
|
f87e70dd53 | ||
|
|
061416be84 | ||
|
|
2688f02a9a
|
||
|
|
0aa9b5907e
|
||
|
|
f8f03b1416
|
||
|
|
40d54226b2
|
||
|
|
4988a0740f
|
||
|
|
6af5062aa7
|
||
|
|
17c7dc1140
|
No files matched your search
+10
-61
@@ -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.7.12
|
||||
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.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -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
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -6,6 +6,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Shared-template APIs for permission- and record-lock-checked variable discovery, typed rendering, and idempotent sends with the exact stored subject and template/version provenance. Checked subject results bind replay to the same recipient, category, sender and linked record.
|
||||
- Optional host template read/write grants without granting provider, message, brand or PHI administration. Existing per-template edit permissions remain additional requirements.
|
||||
- `CRM_LEGACY` templates preserve migrated CRM token replacement, overrides and text-before-HTML behavior inside the email renderer. Native templates retain their existing category and content policies; migration and opt-in remain host responsibilities (see `UPGRADE.md`).
|
||||
|
||||
### Changed
|
||||
- Built on QQQ 4.1.0-RC.8 (default QQQ profile).
|
||||
|
||||
### Added
|
||||
- Central `email.provider.<id>` and `email.readiness` health checks (DEPENDENCY, EMAIL, 300 seconds), using passive provider state and existing category readiness with deadline and secret-safety coverage. Standard Email health status widget links to central checks. Uses the QQQ 4.1.0-RC.8 health API.
|
||||
- Foreign keys between all ten tables in `docs/ddl/email-postgres.sql`, named `fk_<table>_<column>`. CASCADE: a provider's sender addresses and a message's attempts and events. RESTRICT: providers with messages, attempts, events or webhook inbox rows; a brand used by a template; a template with versions. SET NULL: a message's sender address, template, version and brand; a template's active and approved version (the template/version cycle); a suppression's source message and event, so suppressions survive message retention. Ten new indexes cover the unindexed key columns. `docs/ddl/email-foreign-keys-upgrade.sql` adds the keys to an existing database. It first records orphan rows in `qqq_fk_quarantine`: nullable references are set to NULL, and rows whose required parent is missing are deleted and kept as JSON. The script is idempotent. The provider delete check now also refuses providers with attempts, events or inbox rows. `ForeignKeyGuardrailTest` (PostgreSQL 17 via Testcontainers) fails when a join or table possible-value field between email tables has no foreign key.
|
||||
- Starter defaults changelog `db/liquibase/qbit-email/email-defaults.changelog.sql` (insert-only, guarded): one non-default, high-contrast `neutral` brand, only when no brand exists. Tested through real Liquibase on H2, including that it never changes rendering.
|
||||
- Host-neutral in-app help bundle for the Email app, tables, fields, actions and widgets, registered through QQQ's help bundle API. Replaces the temporary inline loader, preserves record-creation explanations and supports HOST overrides. The coverage gate checks owned metadata, summaries and live references.
|
||||
- A Setup card first on the Email home (email admins), using the sender's own provider and brand resolution and `EmailApi.readiness`. Opening the home also creates the built-in secure-message template.
|
||||
- `EmailTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
|
||||
|
||||
### Fixed
|
||||
- The `email.readiness` health message now names each missing item in plain words and where to set it (for example "Brand postal address is not set (Email > Brand > open the default brand > Postal address)") and says transactional email is unaffected by marketing-only gaps. The `failedChecks` detail is unchanged; a new `howToFix` detail lists the same text.
|
||||
- Derive the qbit metadata version from the Maven artifact version so released artifacts report their release version.
|
||||
- `email_template.ownerQbit` was stored but never read, and the built-in template did not set it. The built-in now names `qbit-email`, the field is read-only with help, and messages sent from a template name its owner qbit as `senderQbit` unless the request names one.
|
||||
|
||||
### Added
|
||||
- `EmailJobEffectTarget` automatically registers the durable-jobs `email` target, with idempotent outbox handoff, refusal reasons, reconciliation and job-owner session enforcement; H2 integration coverage and payload/registration documentation.
|
||||
- `EmailApiReadiness.failedChecks()` and `failed(check)` with `EmailApiReadinessCheck` (NOT_INSTALLED, PROVIDER_MISSING, PROVIDER_INVALID, PHI_PROVIDER, PUBLIC_BASE_URL, UNSUBSCRIBE_KEY, BRAND_POSTAL_ADDRESS, BODY_KEY), so callers react to a specific prerequisite without parsing reason text.
|
||||
- `EmailApi.sendOnce` as the public durable-effect handoff, with API-only outcome/result types, committed enqueue/replay semantics and suppression/transaction refusal parity. Exact idempotency-key and RFC Message-ID lookups return `EmailApiMessage`, which now includes `rfcMessageId` across all lookup routes. Public requests support ordinary caller transactions; `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internals. H2 parity, reconciliation, storage-failure and API-boundary coverage; no new dependencies.
|
||||
- Stable `com.kingsrook.qbits.email.api` contract for cross-qbit sends, create-once templates, metadata-only message lookups, enabled sender addresses, default provider selection and secret-safe readiness (including marketing and PHI prerequisites). JDK/QQQ-only signatures, complete request/status mapping tests and a reflection package-boundary guard; no new dependencies.
|
||||
- Documented both supported cross-qbit routes: core-only `SendMessageAction` via `qbitEmail`, and the public API for richer sends and lookups.
|
||||
- Persisted unique RFC Message-IDs, stable across delivery attempts, with SMTP/Gmail SMTP, SES raw MIME and Postmark/SendGrid/Mailgun request propagation (SES replaces the header downstream).
|
||||
- Core-independent `EmailEffects` handoff, reference lookups by idempotency key or RFC Message-ID and refusal mapping; offline retry and provider-contract tests.
|
||||
- Checkstyle now checks test sources as well as main sources; existing test imports and formatting corrected.
|
||||
- Provider sender addresses: child table `email_provider_sender_address` (validated lowercase address, display name, one default, enabled, note; unique per provider; domain bound to the provider's sending domain; admin-only), shown on the provider record. `EmailRequest.withFromAddressId`, `from_address_id` on messages, refusal of a from address that is not an enabled row of the resolved provider, and the provider `from_address` / `from_name` kept as the fallback.
|
||||
- Contingent possible-value filters on the new from-address pickers (send processes by `providerId`, Send Test Email by the acted-on provider, message `fromAddressId` by `providerId`) and on the attempt and event message pickers (by `providerId`) and the suppression source event (by `sourceMessageId`); the send processes also take `providerId`.
|
||||
- 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.
|
||||
@@ -23,3 +58,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
- QBIT_EMAIL core messaging bridge, Review/Result actions, backend send process, dispatch/maintenance/retention schedules and host integration declarations.
|
||||
- H2 concurrency and transaction tests, GreenMail end-to-end tests, fake SES tests, metadata/permission checks and process-runner coverage.
|
||||
- Repository scaffold to the QRun qbit repository standard (Apache-2.0, CircleCI qqq-orb 0.6.8 with the `configure-package-install` pre-step, GA-only `publish_release`, the `release-rc` caller workflow).
|
||||
|
||||
### Removed
|
||||
- The Email home's standalone Provider health listing, including per-provider failure messages, last-success details and rate-headroom queries. Passive delivery health and `email.provider.unhealthy` alerts are retained.
|
||||
@@ -4,16 +4,16 @@ One email service for a QQQ system: a durable outbox (`email_message`) with retr
|
||||
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; and the core messaging bridge. Every send goes through the outbox.
|
||||
real-data home; delivery events, suppression and one-click unsubscribe; and the core messaging bridge. Every send goes through the outbox.
|
||||
|
||||
The [design](docs/DESIGN.md) also covers delivery tracking, unsubscribe, Preferences, the
|
||||
The [design](docs/DESIGN.md) also specifies the deferred Preferences UI (core #1162),
|
||||
consumer migrations and inbound mail. Preview uses the standard widget endpoint; the
|
||||
dedicated preview API arrives with QQQ RC.6. Attachments are refused until
|
||||
qbit-object-storage is wired in.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Java 21, QQQ **4.1.0-RC.5**, and a transactional RDBMS with the supplied unique
|
||||
Requires Java 21, QQQ **4.1.0-RC.8**, and a transactional RDBMS with the supplied unique
|
||||
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
|
||||
The physical table names equal their metadata names; `backendName` selects the host's
|
||||
QQQ database backend, as it does in qbit-webhooks.
|
||||
@@ -39,17 +39,44 @@ The scheduler must already exist. Without a scheduler, callers can enqueue and r
|
||||
Configuration is looked up from the current `QInstance` on every call, never cached
|
||||
in a static variable.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
Include order: the DDL first, then the starter defaults from the host's master changelog (the qbit itself never runs DDL):
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
They are insert-only, hold no secrets, skip a host without the tables, and never change how mail looks on deploy. They add one brand, `neutral` (white background, near-black text, blue buttons, light and dark schemes, every pair at least 4.5:1 contrast), and only when the host has no brand at all. It is not the default brand: copy it for your own, or tick Default Brand on it.
|
||||
|
||||
Everything else starts empty on purpose, and each table's help says why: a **provider** is your own connection (a server and the NAMES of its secret variables), so nothing can be sent until you add one; messages, attempts, events, the webhook inbox and suppressions are runtime data; the secure-message template is created by the qbit on first use and when the Email home opens, and other qbits register theirs with `EmailApi.ensureTemplate`.
|
||||
|
||||
Every table has help that says what it is for and **how its rows get created**, available through the host's help bundle sync. Email admins also get a **Setup** card first on the home: provider, sender addresses, brands, templates, marketing readiness, delivery tracking, suppression list and scheduler, each with its state and the next step. It uses the same provider and brand resolution as the sender and `EmailApi.readiness`.
|
||||
|
||||
### In-app help
|
||||
|
||||
The producer registers [help/qbit-email.yaml](src/main/resources/help/qbit-email.yaml) as a `QBIT` bundle using QQQ's help bundle API. Registration is metadata-only and works without a `helpContent` table; hosts with help enabled load and sync registered bundles through QQQ. The app entry follows `EmailQBitConfig.appName`.
|
||||
|
||||
To override wording or add deployment-specific instructions, register a separate `layer: HOST` bundle with the same entry keys and roles using `HelpBundles.register(qInstance, "help/your-host.yaml")`. HOST entries take precedence over QBIT entries; UI edits remain protected by the core sync. Keep the bundle version aligned with the POM's artifact version when releasing.
|
||||
|
||||
`EmailTablesGuardrailTest.everyTableHasEmptyStateHelp` runs `HelpBundleAssertions` against the produced instance to check owned objects, non-trivial fields, summaries and live references, with no exclusions. `EmailQBitProducerTest` checks registration without a help table and with a custom app name. Both run during `./mvnw verify`.
|
||||
|
||||
A template's **Owner Qbit** (the qbit that registered it) is now used: messages sent from the template name that qbit as their sender unless the request names one.
|
||||
|
||||
## Tables and permissions
|
||||
|
||||
| Table | Navigation | Writes |
|
||||
|---|---|---|
|
||||
| `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
|
||||
@@ -68,7 +95,7 @@ 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 the webhook configuration planned for phase (c).
|
||||
limits, Gmail app passwords and provider webhook setup.
|
||||
|
||||
| Kind | Transport | Status |
|
||||
|---|---|---|
|
||||
@@ -85,6 +112,30 @@ enabled, exactly one must be the enabled default. Switch defaults in a batch upd
|
||||
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
|
||||
@@ -127,10 +178,127 @@ names and STARTTLS. GMAIL_API is refused with
|
||||
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. Outbound tracking settings do not
|
||||
install the event webhook receiver; that arrives in phase (c).
|
||||
`trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c)
|
||||
receiver idempotently when the provided Javalin middleware is present.
|
||||
|
||||
## Sending
|
||||
## Health checks
|
||||
|
||||
The producer registers these checks with QQQ central health monitoring:
|
||||
|
||||
| Name | Kind | Interval |
|
||||
|---|---|---|
|
||||
| `email.provider.<id>` (each enabled provider) | DEPENDENCY | 300 seconds |
|
||||
| `email.readiness` | DEPENDENCY | 300 seconds |
|
||||
|
||||
Both use component `email`, category `EMAIL`, and a 5-second timeout. Provider
|
||||
checks read passive delivery state only: HEALTHY maps to UP, UNHEALTHY to DOWN,
|
||||
and missing/disabled/unknown state to UNKNOWN. The transport SPI has no
|
||||
non-sending connectivity method; these checks never contact providers or send
|
||||
mail. Readiness reuses `EmailApi.readiness` for every `EmailApiCategory`; any
|
||||
unmet prerequisite is DEGRADED, with only enum names in `failedChecks`. Existing
|
||||
passive state and `email.provider.unhealthy` alerts remain in place.
|
||||
|
||||
The Email home uses a standard status widget reading the central `health_component`
|
||||
rollup, with a link to System Health's checks filtered to `email`. It no longer
|
||||
lists provider diagnostics. The host must install central health metadata and its
|
||||
schema; central reads use the viewer's health permissions.
|
||||
|
||||
This integration uses the core health API released in QQQ `4.1.0-RC.8`.
|
||||
Verify offline with `./mvnw -o verify` (`mvnw.cmd` on Windows).
|
||||
|
||||
## Sending from another qbit
|
||||
|
||||
There are two supported routes. Both queue through the durable outbox; a successful
|
||||
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()
|
||||
@@ -241,6 +409,35 @@ sanitizer removes iframes and style blocks, so this preview is not an exact mail
|
||||
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
|
||||
@@ -279,15 +476,17 @@ 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 phase-(c)
|
||||
unsubscribe route and Preferences are still pending: signed links are prepared here,
|
||||
but this qbit alone does not yet serve the unsubscribe destination. Do not deploy
|
||||
opt-out mail without that receiving handler.
|
||||
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. Bounce/complaint totals use dedicated event timestamps, populated
|
||||
by the future webhook phase or host integrations.
|
||||
The home uses viewer-scoped counts and queries, UTC hour/day charts, a central
|
||||
health status line, and 20 recent messages with masked recipients. No sample data or sampled
|
||||
counts are used. Delivered/bounced/complained totals count recipient events and
|
||||
use their occurred dates; sent/dead-letter totals count messages.
|
||||
|
||||
## Delivery and actions
|
||||
|
||||
@@ -331,12 +530,49 @@ Providers offer Send Test Email with a recipient input and a reported outcome.
|
||||
record bindings use `{"patient":{"table":"patient","id":123}}` in
|
||||
`recordVariablesJson`. Output includes `messageId`, `status`, and `reasons`.
|
||||
|
||||
Hidden schedules are `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
|
||||
Hidden schedules include `email_process_webhooks` (30 s), `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
|
||||
recovery including disabled providers, and health probes), and `email_retention`
|
||||
(daily). Retention clears final-message bodies after 30 days, preserving `bodySha256`;
|
||||
message deletion is disabled by default. `finishedDate` records finalization independently
|
||||
of enqueue and later retention updates.
|
||||
|
||||
## Delivery tracking
|
||||
|
||||
Apply the new `email_webhook_inbox` and `email_event` DDL plus indexes for an existing
|
||||
phase-(a)/(b) database. Configure the authenticated HTTPS endpoints using
|
||||
[the provider guide](docs/PROVIDERS.md#event-webhooks). Verification supports SNS
|
||||
RSA v1/v2 with pinned topic and AWS certificate hosts, Postmark Basic auth,
|
||||
SendGrid ECDSA and Mailgun HMAC with durable nonce replay protection.
|
||||
|
||||
The 1 MiB receiver persists before acknowledging. DB transactions serialize
|
||||
processing across replicas and commit event rows, message state and suppression
|
||||
atomically. Events in a payload are applied independently: matched events commit at
|
||||
once, and only unmatched events (or events whose message is still SENDING) retry the
|
||||
row with configured backoff. After six attempts they are skipped and the row finishes
|
||||
PROCESSED with a note ("N events did not match a message"). Rows that cannot be parsed
|
||||
or written fail after six attempts; **Reprocess** on FAILED inbox rows uses the
|
||||
standard Review/Result flow. An event whose recipient is not on the matched message
|
||||
is ignored, and an SES bounce or complaint without its recipient list is skipped.
|
||||
SNS notifications older than an hour or more than five minutes ahead are refused, and
|
||||
certificate fetch failures are cached for five minutes per URL. Disabled tracking is
|
||||
ignored. Per-event recipient keys deduplicate
|
||||
overlapping batches. All new service state is in the DB; certificate caches are in
|
||||
memory. No runtime filesystem or new dependencies are used.
|
||||
|
||||
Hard bounce/complaint suppress ALL; provider unsubscribe suppresses the message's
|
||||
opt-out category. Message status never regresses from COMPLAINED to BOUNCED or
|
||||
DELIVERED. Optional `email.complaint` alerts warn after more than three complaints
|
||||
in 24 hours, with daily deduplication. Provider details mask addresses and are capped
|
||||
at 500 characters; raw authenticated inbox payloads are sensitive DB data.
|
||||
|
||||
NOTIFICATION and MARKETING messages with unsubscribe configured require exactly one
|
||||
To recipient and no Cc/Bcc; send one message per recipient. The token binds that
|
||||
mailbox, and an unsubscribe POST suppresses only that address for the message's
|
||||
category. Multi-recipient requests are refused before suppression filtering.
|
||||
TRANSACTIONAL and SECURITY messages, and NOTIFICATION messages without unsubscribe
|
||||
configured, may still have multiple recipients. Existing single-recipient links
|
||||
remain valid; old multi-recipient links are rejected.
|
||||
|
||||
## Core messaging bridge
|
||||
|
||||
The producer adds messaging provider `qbitEmail` (configurable), type `QBIT_EMAIL`.
|
||||
@@ -354,17 +590,10 @@ The bridge recursively maps Party/MultiParty TO, CC, BCC, FROM and REPLY_TO role
|
||||
TEXT/HTML alternatives. REFUSED throws `QException`; successful calls acknowledge
|
||||
queueing, not delivery. The built-in EMAIL and SES types are unchanged.
|
||||
|
||||
For schema-aware core bridge calls, use the typed extension:
|
||||
|
||||
```java
|
||||
new SendMessageAction().execute(new EmailTemplatedMessageInput()
|
||||
.withEmailRequest(new EmailRequest().withTemplateName("email_secure_message")
|
||||
.withTo("pat@example.com").withVariable("link", "https://app.example.com/inbox"))
|
||||
.withMessagingProviderName("qbitEmail"));
|
||||
```
|
||||
|
||||
Ordinary core `SendMessageInput` remains ad-hoc and cannot infer classifications
|
||||
from arbitrary pre-rendered text. Use the typed extension for classified variables.
|
||||
For schema-aware calls from another qbit, use `EmailApi.send` with an
|
||||
`EmailApiRequest` and declared template variables as described above. Ordinary
|
||||
core `SendMessageInput` is ad-hoc and cannot infer classifications from arbitrary
|
||||
pre-rendered text. The existing typed bridge extension is an internal interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -375,6 +604,7 @@ All settings have fluent `withX` setters and getters.
|
||||
| `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 |
|
||||
@@ -405,6 +635,32 @@ 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
|
||||
|
||||
Built on QQQ **4.1.0-RC.8**. Enable `DurableJobsMetaData` and define the jobs tables
|
||||
**before** producing the email qbit: `EmailQBitProducer` automatically registers
|
||||
`EmailJobEffectTarget` under target name `email`. If jobs are enabled later, call
|
||||
`EmailJobEffectTarget.register(instance)` afterward. Registration is a no-op when
|
||||
jobs are disabled.
|
||||
|
||||
Declare `new JobEffectDeclaration("welcomeEmail", "<step>", "email",
|
||||
JobEffectType.IDEMPOTENT_EXTERNAL)` in the process's `DurableProcessMetaData`, then
|
||||
call `JobEffects.emit(input, "welcomeEmail", rowKey, payload)` in the backend step.
|
||||
The payload accepts recipients, template or ad-hoc bodies, variables, routing and
|
||||
record linkage; see the [complete payload contract and examples](docs/DURABLE-JOBS.md).
|
||||
|
||||
The target deduplicates handoffs by the effect key. SENT means the email outbox has
|
||||
committed, while its dispatcher owns provider delivery and retries. Job effects
|
||||
send under the job owner's current session and captured security scope; an owner
|
||||
whose session cannot be established produces FAILED. Other exceptions produce
|
||||
UNKNOWN and can safely be retried using the same key. Reconcile finds the existing
|
||||
outbox row, including terminal audit rows.
|
||||
|
||||
Each message retains its stored RFC Message-ID across provider attempts. SMTP,
|
||||
Postmark, SendGrid and Mailgun carry it; SES replaces it downstream. This supports
|
||||
correlation and receiver deduplication where available, without guaranteeing
|
||||
exactly-once inbox delivery. See the [schema upgrade and provider notes](docs/DURABLE-JOBS.md).
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased: shared CRM templates
|
||||
|
||||
Existing hosts and callers need no configuration change. This release adds APIs and a template kind; it does not migrate CRM rows or enable shared CRM templates automatically. The existing VARCHAR kind column needs no email schema change. Hosts with additional kind constraints must allow `CRM_LEGACY` before importing migrated rows.
|
||||
|
||||
- To grant CRM users template access, configure `EmailQBitConfig.withTemplatePermissionBaseName("crm.manage")`. The resulting `.read` and `.write` permissions apply to template/version data and the New Version/Activate Version actions, plus app navigation. They do not grant provider, brand, delivery, message or PHI approval access. A template's `editPermissionName` is still an additional write requirement, and record security locks still apply.
|
||||
- `EmailApi.templateVariableNames` and `renderTemplate` read templates and active versions in the caller's session. Rendering requires an expected delivery category and returns typed content and provenance. Native HTML must not be interpreted as Markdown. Native templates reject category/content overrides, and PHI-approved templates require the existing protected send path. Preview content is not a substitute for the actual queued message subject or delivery envelope.
|
||||
- `EmailApi.sendTemplateOnce` uses the existing outbox and suppression rules, returning the exact stored subject only for a checked template/version and matching sender, linked record, category and single-recipient envelope. Supply sender qbit/process and linked table/id; CC/BCC and multiple parsed mailboxes are unsupported. Existing metadata-only lookup APIs remain unchanged. A replay after active-version or envelope changes can be rejected: reconcile the existing effect rather than automatically retrying with a new key.
|
||||
- `CRM_LEGACY` is for migrated templates owned by `crm`, without PHI approval or record bindings. It preserves the five historical CRM substitutions in a single pass, leaves unknown syntax literal, prefers text source over HTML fallback, and supports historical subject/body overrides. It permits the caller's transactional or marketing category while retaining delivery suppression and readiness checks. Native kinds keep their existing policies.
|
||||
- Release the public email artifact before pinning the adopting CRM release. CRM/host adoption must explicitly opt in, migrate populated rows and references, and preserve provenance and rollback data during a documented maintenance window. Optional tenant CRM hosts must retain their legacy path until a supported tenant migration exists. Local verification artifacts such as `0.1.0-LOCAL-270` must never become public dependency pins.
|
||||
|
||||
## Unreleased: starter defaults, setup card, table help
|
||||
|
||||
1. **Add the defaults changelog** after the changesets that create the `email_*` tables (`docs/ddl/email-postgres.sql`) in the host's master changelog:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It is insert-only and guarded: a host that already has any brand gets nothing, a host without the table skips it. The `neutral` brand it adds is not the default brand, so existing mail renders exactly as before.
|
||||
2. **Behavior change.** Messages sent from a template whose Owner Qbit is set, with no sender named in the request, now record that qbit as `senderQbit` (it was empty). Nothing else about sending changes.
|
||||
3. The help bundle and the Setup card need no configuration. Existing built-in templates created before this release keep an empty Owner Qbit.
|
||||
+107
-16
@@ -1,6 +1,6 @@
|
||||
# qbit-email design
|
||||
|
||||
**Status:** phase (a) implemented; later phases remain specification. Phases (a) to (d) build outbound; phase (e) is inbound (section 12, design only).
|
||||
**Status:** phases (a), (b) and (c) implemented, except Preferences (8.3), deferred until core #1162 ships in an RC. Phase (d) consumer migrations and phase (e) inbound remain specification.
|
||||
**Brief:** owner decisions of 2026-10-13 (providers, outbound first, both template kinds).
|
||||
**Owner decisions (2026-10-12): all recommendations accepted.** Every "Recommended" choice and
|
||||
every decision below stands; it is adjusted once it can be seen working.
|
||||
@@ -44,7 +44,7 @@ unsubscribe and PHI rules.
|
||||
|
||||
## 2. Sending API
|
||||
|
||||
### 2.1 `EmailService` (Java, for every consumer)
|
||||
### 2.1 `EmailService` (internal Java enqueue service)
|
||||
|
||||
```java
|
||||
EmailSendResult result = EmailService.send(new EmailRequest()
|
||||
@@ -54,6 +54,7 @@ EmailSendResult result = EmailService.send(new EmailRequest()
|
||||
.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
|
||||
@@ -73,7 +74,8 @@ EmailSendResult result = EmailService.send(new EmailRequest()
|
||||
|
||||
### 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, brand) for scripts, the API and other qbits. Not in the nav;
|
||||
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
|
||||
@@ -94,9 +96,58 @@ template may set `edit_permission_name` to restrict who edits it (e.g. marketing
|
||||
- Attachments are stored through qbit-object-storage when it is installed, else refused with a
|
||||
plain message (no blobs in the outbox, nothing on local disk: D11).
|
||||
- Callers (qbit-alerts `EmailChannel` and `NotificationEmailer`, qbit-marketing
|
||||
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailService`
|
||||
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailApi`
|
||||
in phase (d) for templates, categories, idempotency and linked records.
|
||||
|
||||
### 2.5 Public API
|
||||
|
||||
`com.kingsrook.qbits.email.api.EmailApi` is the sole public qbit-email surface for consuming qbits.
|
||||
Changes within a `0.x` minor release line are additive only. Public/protected
|
||||
signatures contain only JDK, QQQ core or API-package types; internal packages are
|
||||
not API and may change. A reflection test discovers every compiled API class and
|
||||
checks signatures, including generic arguments, bounds and nested types.
|
||||
|
||||
- `EmailApi.send(EmailApiRequest)` maps to the existing outbox service, preserving
|
||||
permissions, schema/PHI validation, suppression and one-recipient rules. Requests
|
||||
cover recipients/sender selection, body alternatives, template variables and record
|
||||
provenance, category/provider/brand/locale, idempotency, linked records, producer
|
||||
identity, scheduling and caller transactions (`withTransaction(transaction, backendName)`).
|
||||
Results expose message id, enqueue status and reasons.
|
||||
- `sendOnce(EmailApiRequest, effectKey)` provides durable handoff through the existing
|
||||
internal effects implementation. The key becomes the request's idempotency key;
|
||||
null requests, missing/blank/over-500-character keys, mismatches and caller transactions
|
||||
are refused. `EmailApiEffectResult` carries `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE`
|
||||
or `REFUSED` and nullable `EmailApiMessage`. Success means committed outbox storage,
|
||||
and replays retain the row and RFC Message-ID. Suppressed audit rows remain refused
|
||||
on replay and return metadata; refusals without a row return null. Operational
|
||||
failures throw `QException`. The deprecated `EmailEffects`, `EmailEffectOutcome` and
|
||||
`EmailMessageRef` remain internal; consumers use this API. See [durable jobs](DURABLE-JOBS.md).
|
||||
- `ensureTemplate(EmailApiTemplate)` delegates create-once template registration;
|
||||
`EmailApiVariable` carries the variable contract. Existing content is never
|
||||
overwritten, and the API does not confer PHI approval.
|
||||
- `getMessage`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and
|
||||
bounded, literal `findMessagesByIdempotencyKeyPrefix` perform
|
||||
system reads of an explicit metadata projection: id/status/category, idempotency
|
||||
key, RFC Message-ID and provider id, template name, linked record and create/sent/delivered dates.
|
||||
Exact-key lookups return empty for null/blank/missing values. RFC lookup requires
|
||||
the exact bracketed header stored at enqueue time, distinct from provider response ids.
|
||||
No recipients, subjects, bodies or snapshots are fetched or returned by lookups.
|
||||
Callers own authorization before presenting metadata to users.
|
||||
- `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace
|
||||
consumer access to runtime/configuration/tables. Readiness checks an enabled
|
||||
default provider; MARKETING also needs a valid publicBaseUrl, available unsubscribe
|
||||
key and default brand postal address. The PHI overload checks body key availability
|
||||
and provider PHI/tracking policy. Reasons never contain resolved secret values.
|
||||
PHI is a template state, so it is an explicit option rather than a fifth category.
|
||||
- Database operations use the current `QContext`; an installed instance argument
|
||||
must match that context. Database errors remain core `QException`. Readiness does
|
||||
not validate an individual request or guarantee delivery.
|
||||
|
||||
For simple subject/body sends, consumers need no qbit-email dependency: use core
|
||||
`SendMessageAction` with `messagingProviderName = "qbitEmail"` (or the host's
|
||||
configured bridge name). Use the API package for templates, idempotency, links and
|
||||
lookups. Both routes enqueue; neither sends inline.
|
||||
|
||||
## 3. Providers and transports
|
||||
|
||||
### 3.1 `email_provider`
|
||||
@@ -122,7 +173,16 @@ They are never stored, logged, audited, put in process output or returned. The r
|
||||
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.
|
||||
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
|
||||
@@ -174,6 +234,7 @@ transient; other 4xx are permanent.
|
||||
| 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 |
|
||||
@@ -205,7 +266,9 @@ error) and `email_event` (section 6).
|
||||
content), Cancel (queued or waiting), Send Now (waiting). Standard streamed-ETL Review and Result
|
||||
screens.
|
||||
- **Retention:** a daily job clears bodies after `bodyRetentionDays` and deletes final messages
|
||||
older than `messageRetentionDays` (0 = keep forever; kof22 keeps).
|
||||
older than `messageRetentionDays` (0 = keep forever; kof22 keeps). NOTIFICATION and MARKETING
|
||||
messages are never deleted, because their unsubscribe links resolve through the message row
|
||||
(8.2); at `messageRetentionDays` retention only clears their bodies and variable snapshot.
|
||||
|
||||
## 5. Templates and branding
|
||||
|
||||
@@ -283,11 +346,10 @@ id, or unsaved source, plus variables) returns subject, html, text and warnings.
|
||||
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 have dedicated columns for phase-(c) events; no dates or metrics are invented.
|
||||
- Part 2 signs opt-out links and prepares headers; the receiving unsubscribe handler
|
||||
remains phase (c). Without its key/base URL, notification omits opt-out links and
|
||||
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 covers the recipient envelope.
|
||||
without a receiving handler. The token's address hash binds the single recipient mailbox.
|
||||
|
||||
## 6. Delivery tracking
|
||||
|
||||
@@ -312,6 +374,32 @@ DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED), bounce_
|
||||
UNDETERMINED), recipient, occurred_date, detail (the provider's reason, truncated; never body
|
||||
content), url (clicks only). Opens and clicks are stored only when the provider row enables them.
|
||||
|
||||
### 6.3 Phase (c) implementation
|
||||
- Inbox states: PENDING, PROCESSED, FAILED, IGNORED. Retry after configured backoff,
|
||||
FAILED after six attempts, and a standard Reprocess action resets FAILED to PENDING.
|
||||
- Events in one payload are applied independently. Only unmatched events (or events whose
|
||||
message is still SENDING) defer the row; on the sixth attempt they are skipped and the row is
|
||||
PROCESSED with the note "N events did not match a message". Suppression applies only to a
|
||||
recipient in the matched message's to, cc or bcc; an SES bounce or complaint without its
|
||||
recipient list is skipped, never widened to all destinations. SNS notifications must carry a
|
||||
Timestamp no older than 1 hour and no more than 5 minutes ahead, and certificate fetch
|
||||
failures are cached for 5 minutes per URL.
|
||||
- A short DB transaction locks the common provider gate then the actual provider;
|
||||
it rechecks the observed inbox attempt and commits all changes together. No worker
|
||||
filesystem, extra claim table, in-process scheduler or network operation is needed
|
||||
during processing. A crash rolls back the transaction and releases the claim.
|
||||
- `email_event.event_key` provides unique recipient/event deduplication across
|
||||
overlapping batches, alongside inbox uniqueness on provider/event and provider/nonce.
|
||||
- Message correlation always stays within the authenticated provider. Unknown message
|
||||
ids retry (including callbacks before SENT); unsupported event kinds are ignored.
|
||||
- Opens/clicks require current provider flags and are also dropped for PHI-approved
|
||||
messages. Event details mask addresses before truncation to 500 characters.
|
||||
- The unsubscribe signer binds one mailbox. A valid POST suppresses only that address
|
||||
for MARKETING/NOTIFICATION; the page never displays addresses. Legacy links remain
|
||||
valid only for messages with one To recipient and no Cc/Bcc.
|
||||
- PostgreSQL and H2 definitions, crypto fixtures and real random-port Javalin tests
|
||||
are included. Certificate caching is memory-only. Setup is in `PROVIDERS.md`.
|
||||
|
||||
## 7. PHI and PII
|
||||
|
||||
### 7.1 Classification
|
||||
@@ -371,6 +459,7 @@ MANUAL), source_message_id, source_event_id, note, created_date, expires_date (o
|
||||
respects HARD_BOUNCE and COMPLAINT.
|
||||
|
||||
### 8.2 One-click unsubscribe (RFC 8058, RFC 2369)
|
||||
- NOTIFICATION or MARKETING messages carrying an unsubscribe link/header must have exactly one To recipient and no Cc/Bcc; send one message per recipient.
|
||||
- MARKETING and NOTIFICATION messages carry
|
||||
`List-Unsubscribe: <https://{publicBaseUrl}/qqq/hooks/email/unsubscribe/{token}>` and
|
||||
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and the footer links to the same URL.
|
||||
@@ -379,11 +468,11 @@ MANUAL), source_message_id, source_event_id, note, created_date, expires_date (o
|
||||
MARKETING sends are refused. Tokens do not expire (RFC 8058 expects the link to keep working).
|
||||
- `POST` with `List-Unsubscribe=One-Click` records the suppression and returns 200. `GET` returns a
|
||||
minimal page with one "Unsubscribe" button that POSTs, because mail scanners prefetch GETs; then
|
||||
"You are unsubscribed from {category}", with a link to Preferences.
|
||||
"You are unsubscribed from {category}". A Preferences link awaits core #1162.
|
||||
- An `UNSUBSCRIBED` event is recorded on the message.
|
||||
|
||||
### 8.3 Preferences (core #1162)
|
||||
The qbit registers a Preferences section `email` ("Email") whose provider
|
||||
**Deferred: core #1162 is not in an RC.** The planned qbit registers a Preferences section `email` ("Email") whose provider
|
||||
(`EmailPreferenceProvider implements UserPreferenceProviderInterface`) maps the signed-in user's
|
||||
address (`UserPreferenceRequestContext.getUserEmailAddress()`, never an address from the request)
|
||||
to `email_suppression` rows: one toggle per opt-out-able category (NOTIFICATION,
|
||||
@@ -452,13 +541,15 @@ section's description explains the split with qbit-alerts' Notifications section
|
||||
| `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` (phase a).
|
||||
Postgres DDL in `docs/ddl/email-postgres.sql` (phases a-c).
|
||||
|
||||
## 14. Configuration (`EmailQBitConfig`)
|
||||
backendName (required), schedulerName (required to send), publicBaseUrl (unsubscribe links),
|
||||
backendName (required), schedulerName (required for automatic processing), publicBaseUrl (unsubscribe links),
|
||||
webhookBasePath (`/qqq/hooks/email`, shared by tracking and unsubscribe),
|
||||
defaultProviderName, bridgeProviderName (`qbitEmail`), secretResolver, allowedEnvNames,
|
||||
dispatchIntervalSeconds (15), batchSize (100), maxAttempts (6), retryMinutes, leaseSeconds (120),
|
||||
bodyRetentionDays (30), messageRetentionDays (0), mjmlCompiler, classificationOverrides, appName
|
||||
@@ -469,12 +560,12 @@ and appLabel, securityFields and recordSecurityLocks (multi-tenant hosts).
|
||||
|---|---|---|
|
||||
| (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 section | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
|
||||
| (c) | webhook route and inbox, events, suppression, unsubscribe (Preferences deferred) | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
|
||||
| (d) | consumer migrations | each consumer's own tests; a contract test of `EmailService` |
|
||||
| (e) | inbound | design only for now |
|
||||
|
||||
Dependencies: phase (a) builds on QQQ 4.1.0-RC.5 with the classification stand-in, and moves to
|
||||
RC.6 for `DataClassification` and Preferences.
|
||||
later RCs for `DataClassification` and Preferences when their APIs are released.
|
||||
|
||||
## 16. Owner steps (outside code)
|
||||
- **PMG (kof22):** none; the relay already accepts the cluster.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Durable job email effects
|
||||
|
||||
QQQ 4.1.0-RC.7 supplies the durable jobs side-effects outbox. QBit Email registers
|
||||
`com.kingsrook.qbits.email.outbox.EmailJobEffectTarget` as target `email` to hand
|
||||
those effects to its own committed outbox. All provider sending remains asynchronous.
|
||||
|
||||
## Stored identity and schema upgrade
|
||||
|
||||
`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.
|
||||
|
||||
## Register, declare and emit
|
||||
|
||||
Enable durable jobs **before** producing the email qbit, so the producer can register
|
||||
its target automatically:
|
||||
|
||||
```java
|
||||
instance.withSupplementalMetaData(new DurableJobsMetaData());
|
||||
new DurableJobsMetaDataProvider().defineAll(instance, "rdbms", null);
|
||||
new EmailQBitProducer()
|
||||
.withQBitConfig(new EmailQBitConfig().withBackendName("rdbms"))
|
||||
.produce(instance);
|
||||
```
|
||||
|
||||
Apply core's jobs database schema as usual. `EmailJobEffectTarget.register(instance)`
|
||||
is a no-op without `DurableJobsMetaData`. If jobs are enabled after producing email,
|
||||
call this helper explicitly after enabling them.
|
||||
|
||||
Declare the effect on the process's `DurableProcessMetaData` (retain any existing
|
||||
durable settings). Replace `<step>` with the process's real backend step name:
|
||||
|
||||
```java
|
||||
process.withSupplementalMetaData(new DurableProcessMetaData()
|
||||
.withEffect(new JobEffectDeclaration("welcomeEmail", "<step>", "email",
|
||||
JobEffectType.IDEMPOTENT_EXTERNAL)));
|
||||
```
|
||||
|
||||
Then emit from that step using its `RunBackendStepInput input` and a stable row key:
|
||||
|
||||
```java
|
||||
Map<String, Serializable> payload = new LinkedHashMap<>();
|
||||
payload.put("to", "person@example.com");
|
||||
payload.put("subject", "Welcome");
|
||||
payload.put("textBody", "Your account is ready.");
|
||||
JobEffects.emit(input, "welcomeEmail", rowKey, payload);
|
||||
```
|
||||
|
||||
The jobs classes above come from `com.kingsrook.qqq.backend.core.jobs` and its
|
||||
`effects` package; `Map`, `LinkedHashMap` and `Serializable` are JDK types.
|
||||
Core stores the effect with the chunk's progress and dispatches after commit.
|
||||
Outside a durable job, `JobEffects.emit` calls the target immediately with a fresh
|
||||
key, still only enqueueing email; a result other than SENT throws.
|
||||
|
||||
Use **IDEMPOTENT_EXTERNAL**: qbit-email deduplicates by the effect's exact key,
|
||||
while its own delivery outbox reuses the stored Message-ID on provider attempts.
|
||||
This acknowledges one durable handoff, not exactly-once delivery to a mailbox.
|
||||
|
||||
## Payload contract
|
||||
|
||||
Unknown keys are ignored. Recognized values must have the following types:
|
||||
|
||||
| Keys | Values |
|
||||
|---|---|
|
||||
| `to`, `cc`, `bcc` | A String mailbox or a list of String mailboxes; omitted means empty. Comma-separated strings are not split. |
|
||||
| `subject`, `templateName` | Strings; ordinary template/ad-hoc validation applies. |
|
||||
| `markdownBody`, `htmlBody`, `textBody` | String body alternatives. |
|
||||
| `variables` | Map with String keys and serializable values; requires a template schema and retains the normal PHI/classification checks. |
|
||||
| `category` | String enum name: `TRANSACTIONAL`, `SECURITY`, `NOTIFICATION`, or `MARKETING`. |
|
||||
| `providerName`, `brandName`, `locale` | Strings; omitted locale retains `en`. |
|
||||
| `from`, `replyTo` | String mailboxes, subject to ordinary sender validation. |
|
||||
| `fromAddressId` | Integer identifying an enabled address for the selected provider. |
|
||||
| `sendAfter` | ISO-8601 Instant String, for example `2026-10-14T12:00:00Z`. |
|
||||
| `linkedTableName`, `linkedRecordId` | String table name; record id converted to String. |
|
||||
|
||||
The target always sets `senderQbit = "jobs"`, `senderProcess = effectName`, and
|
||||
uses `EffectRequest.idempotencyKey()`. Payload keys cannot override these or attach
|
||||
a transaction. `recordVariables`, `securityValues` and `payloadRef` are not decoded;
|
||||
provide the supported values directly in `payload`.
|
||||
|
||||
## Identity and outcomes
|
||||
|
||||
The dispatcher invokes the target as the system user. When `jobId` is present, the
|
||||
target loads the job under core's system scope and builds its owner session through
|
||||
`JobSessions.forJob`. Sending runs inside `QContext.withTemporaryContext` using that
|
||||
owner session, with current permissions and the intersection of current and captured
|
||||
security keys. The original dispatcher context is restored even after an exception.
|
||||
Without a job id, sending retains the caller's session.
|
||||
|
||||
- `QUEUED_OR_DUPLICATE` becomes `EffectResult.sent(outboxId)`; `providerRef` is the
|
||||
String email row id, not the transport provider's reference.
|
||||
- `REFUSED` becomes `EffectResult.failed(reasons)`, including suppressed replays.
|
||||
- A missing job or an owner session rejected by authentication/permission validation
|
||||
becomes FAILED with `The job's owner can no longer send it: ...`.
|
||||
- Other exceptions, including payload decoding and database failures, become UNKNOWN.
|
||||
Error text omits raw exception details to avoid exposing payloads or database values.
|
||||
Core may resend IDEMPOTENT_EXTERNAL effects using the same key.
|
||||
- Reconcile uses `EmailEffects.findByIdempotencyKey`: any existing row becomes FOUND
|
||||
with its id, otherwise NOT_FOUND. Lookup failures propagate rather than falsely
|
||||
reporting absence. Reconcile never creates mail or changes the owner session.
|
||||
|
||||
The binding uses `EmailEffects` internally; other consuming qbits continue to use
|
||||
`EmailApi`. The H2 target tests install core jobs metadata with `defineAll`, create
|
||||
all columns of the `jobs_job` lookup table, and observe the owner through an insert
|
||||
customizer because email messages have no built-in created-by/user field. No
|
||||
production job-lookup test seam is needed.
|
||||
|
||||
Tests use H2, the existing GreenMail SMTP sink, fake SES and HTTP request seams;
|
||||
no vendor endpoints are called. The PostgreSQL migration is supplied for host
|
||||
deployment and is not executed by the offline Java test gate.
|
||||
+68
-18
@@ -191,25 +191,75 @@ 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)
|
||||
## Event webhooks
|
||||
|
||||
Event ingestion is **not installed by phase (b)**. After phase (c) is deployed,
|
||||
configure provider event delivery at:
|
||||
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`.
|
||||
|
||||
`https://<app>/qqq/hooks/email/{kind}/{providerName}`
|
||||
|
||||
Use the configured provider name and provider-kind route defined by phase (c).
|
||||
Do not expect delivery/bounce/complaint/open/click updates until the receiver
|
||||
exists. Tracking options can operate on the provider side now.
|
||||
|
||||
| Provider | Event setup to prepare |
|
||||
| Provider | Setup and events |
|
||||
|---|---|
|
||||
| Postmark | Server/stream event webhooks with Basic auth; `POSTMARK_WEBHOOK_PASSWORD` |
|
||||
| SendGrid | Signed Event Webhook; verification key in `SENDGRID_WEBHOOK_PUBLIC_KEY` |
|
||||
| Mailgun | Regional domain event webhooks; `MAILGUN_WEBHOOK_SIGNING_KEY`, separate from the sending key |
|
||||
| SES | Configuration-set events to SNS, HTTP subscription and `sns_topic_arn`; signature/topic verification arrives in phase (c) |
|
||||
| SMTP / Gmail SMTP | No native provider event webhook in this design |
|
||||
| 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. |
|
||||
|
||||
Webhook environment names are allowlisted now so rows can be prepared; outbound
|
||||
sends never resolve them. Verification, durable event processing and unsubscribe
|
||||
handling arrive in later phases.
|
||||
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.
|
||||
@@ -0,0 +1,89 @@
|
||||
-- qbit-email: add the foreign keys to an existing PostgreSQL schema (one
|
||||
-- created from email-postgres.sql before they were declared there). Pause
|
||||
-- enqueue, dispatch and webhook processing while it runs.
|
||||
--
|
||||
-- Safe to run more than once: a foreign key that already exists with the
|
||||
-- right ON DELETE (under any name) is left alone. Before each key is added,
|
||||
-- rows that point at a missing parent are recorded in qqq_fk_quarantine and
|
||||
-- then set to NULL (nullable column) or deleted (NOT NULL column; the whole
|
||||
-- row is kept in row_data). An existing key on the column with a different
|
||||
-- ON DELETE is replaced by the named one.
|
||||
--
|
||||
-- Order matters: sender addresses, attempts and events of a missing parent are
|
||||
-- removed before the references to them (message.from_address_id,
|
||||
-- suppression.source_event_id) are checked.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.qqq_add_fk(p_table TEXT, p_column TEXT, p_parent TEXT, p_on_delete TEXT) RETURNS VOID LANGUAGE plpgsql AS
|
||||
$fn$
|
||||
DECLARE
|
||||
v_name TEXT := 'fk_' || p_table || '_' || p_column;
|
||||
v_rule "char" := CASE p_on_delete WHEN 'CASCADE' THEN 'c' WHEN 'SET NULL' THEN 'n' WHEN 'RESTRICT' THEN 'r' ELSE 'a' END;
|
||||
v_nullable BOOLEAN;
|
||||
v_changeset TEXT := 'qbit-email:email-foreign-keys-upgrade.sql';
|
||||
v_old RECORD;
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column
|
||||
AND c.confrelid = p_parent::regclass AND c.confdeltype = v_rule) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
FOR v_old IN SELECT c.conname FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column LOOP
|
||||
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', p_table, v_old.conname);
|
||||
END LOOP;
|
||||
|
||||
SELECT is_nullable = 'YES' INTO v_nullable FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = p_table AND column_name = p_column;
|
||||
|
||||
IF v_nullable THEN
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) '
|
||||
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''NULLED'', %L FROM %I t WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, v_changeset, p_table, p_column, p_parent, p_column);
|
||||
EXECUTE format('UPDATE %I t SET %I = NULL WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, p_parent, p_column);
|
||||
ELSE
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) '
|
||||
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''DELETED'', row_to_json(t)::TEXT, %L FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, v_changeset, p_table, p_parent, p_column);
|
||||
EXECUTE format('DELETE FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)', p_table, p_parent, p_column);
|
||||
END IF;
|
||||
|
||||
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I (id) ON DELETE %s', p_table, v_name, p_column, p_parent, p_on_delete);
|
||||
END
|
||||
$fn$;
|
||||
|
||||
SELECT pg_temp.qqq_add_fk('email_provider_sender_address', 'provider_id', 'email_provider', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('email_message', 'provider_id', 'email_provider', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_message', 'from_address_id', 'email_provider_sender_address', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_message', 'template_id', 'email_template', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_message', 'template_version_id', 'email_template_version', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_message', 'brand_id', 'email_brand', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_template', 'brand_id', 'email_brand', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_template', 'active_version_id', 'email_template_version', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_template', 'approved_version_id', 'email_template_version', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_template_version', 'template_id', 'email_template', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_message_attempt', 'message_id', 'email_message', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('email_message_attempt', 'provider_id', 'email_provider', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_event', 'message_id', 'email_message', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('email_event', 'provider_id', 'email_provider', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_webhook_inbox', 'provider_id', 'email_provider', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('email_suppression', 'source_message_id', 'email_message', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('email_suppression', 'source_event_id', 'email_event', 'SET NULL');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_message_from_address ON email_message (from_address_id);
|
||||
CREATE INDEX IF NOT EXISTS email_message_template ON email_message (template_id);
|
||||
CREATE INDEX IF NOT EXISTS email_message_template_version ON email_message (template_version_id);
|
||||
CREATE INDEX IF NOT EXISTS email_message_brand ON email_message (brand_id);
|
||||
CREATE INDEX IF NOT EXISTS email_template_brand ON email_template (brand_id);
|
||||
CREATE INDEX IF NOT EXISTS email_template_active_version ON email_template (active_version_id);
|
||||
CREATE INDEX IF NOT EXISTS email_template_approved_version ON email_template (approved_version_id);
|
||||
CREATE INDEX IF NOT EXISTS email_suppression_source_message ON email_suppression (source_message_id);
|
||||
CREATE INDEX IF NOT EXISTS email_suppression_source_event ON email_suppression (source_event_id);
|
||||
CREATE INDEX IF NOT EXISTS email_event_provider ON email_event (provider_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase (a), parts 1 and 2. Apply before producing the qbit. Host security columns must be added by the host.
|
||||
-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host.
|
||||
|
||||
CREATE TABLE email_provider (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -38,6 +38,19 @@ CREATE TABLE email_provider (
|
||||
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,
|
||||
@@ -47,6 +60,7 @@ CREATE TABLE email_message (
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
from_address VARCHAR(500),
|
||||
from_address_id INTEGER,
|
||||
reply_to VARCHAR(500),
|
||||
subject TEXT,
|
||||
template_id INTEGER,
|
||||
@@ -61,6 +75,7 @@ CREATE TABLE email_message (
|
||||
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,
|
||||
@@ -79,7 +94,8 @@ CREATE TABLE email_message (
|
||||
sender_process VARCHAR(500),
|
||||
suppressed_addresses TEXT,
|
||||
send_after TIMESTAMP,
|
||||
UNIQUE (idempotency_key)
|
||||
UNIQUE (idempotency_key),
|
||||
UNIQUE (rfc_message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message_attempt (
|
||||
@@ -184,3 +200,81 @@ CREATE TABLE email_suppression (
|
||||
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
|
||||
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
|
||||
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
|
||||
|
||||
CREATE TABLE email_webhook_inbox (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
provider_id INTEGER NOT NULL,
|
||||
kind VARCHAR(500) NOT NULL,
|
||||
external_event_id VARCHAR(500) NOT NULL,
|
||||
nonce VARCHAR(500),
|
||||
status VARCHAR(500) NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_date TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
payload_json TEXT,
|
||||
payload_sha256 VARCHAR(500),
|
||||
received_date TIMESTAMP,
|
||||
processed_date TIMESTAMP,
|
||||
UNIQUE (provider_id, external_event_id),
|
||||
UNIQUE (provider_id, nonce)
|
||||
);
|
||||
|
||||
CREATE TABLE email_event (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
message_id INTEGER NOT NULL,
|
||||
provider_id INTEGER,
|
||||
event_type VARCHAR(500) NOT NULL,
|
||||
bounce_type VARCHAR(500),
|
||||
recipient VARCHAR(500),
|
||||
occurred_date TIMESTAMP,
|
||||
detail VARCHAR(500),
|
||||
url TEXT,
|
||||
event_key VARCHAR(500) NOT NULL UNIQUE
|
||||
);
|
||||
CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date);
|
||||
CREATE INDEX email_event_message ON email_event (message_id);
|
||||
CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date);
|
||||
CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);
|
||||
|
||||
-- Foreign keys (named fk_<table>_<column>), added after every table exists.
|
||||
-- CASCADE owned rows: a provider's sender addresses (the provider delete also removes
|
||||
-- them), a message's attempts and events (message retention deletes them first).
|
||||
-- RESTRICT configuration under live data: providers with messages, attempts, events or
|
||||
-- webhook inbox rows (disable the provider instead); a brand used by a template;
|
||||
-- a template with versions (templates with versions cannot be deleted).
|
||||
-- SET NULL optional pointers: a message's sender address, template, version and brand
|
||||
-- (the rendered body is kept); a template's active/approved version (the
|
||||
-- cycle with email_template_version.template_id; drafts can be deleted); a
|
||||
-- suppression's source message and event, which outlive message retention.
|
||||
-- Existing databases: run email-foreign-keys-upgrade.sql.
|
||||
ALTER TABLE email_provider_sender_address ADD CONSTRAINT fk_email_provider_sender_address_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE CASCADE;
|
||||
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
|
||||
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_from_address_id FOREIGN KEY (from_address_id) REFERENCES email_provider_sender_address (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_template_version_id FOREIGN KEY (template_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_message ADD CONSTRAINT fk_email_message_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
|
||||
ALTER TABLE email_message_attempt ADD CONSTRAINT fk_email_message_attempt_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
|
||||
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_brand_id FOREIGN KEY (brand_id) REFERENCES email_brand (id) ON DELETE RESTRICT;
|
||||
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_active_version_id FOREIGN KEY (active_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_template ADD CONSTRAINT fk_email_template_approved_version_id FOREIGN KEY (approved_version_id) REFERENCES email_template_version (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_template_version ADD CONSTRAINT fk_email_template_version_template_id FOREIGN KEY (template_id) REFERENCES email_template (id) ON DELETE RESTRICT;
|
||||
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_message_id FOREIGN KEY (source_message_id) REFERENCES email_message (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_suppression ADD CONSTRAINT fk_email_suppression_source_event_id FOREIGN KEY (source_event_id) REFERENCES email_event (id) ON DELETE SET NULL;
|
||||
ALTER TABLE email_webhook_inbox ADD CONSTRAINT fk_email_webhook_inbox_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
|
||||
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_message_id FOREIGN KEY (message_id) REFERENCES email_message (id) ON DELETE CASCADE;
|
||||
ALTER TABLE email_event ADD CONSTRAINT fk_email_event_provider_id FOREIGN KEY (provider_id) REFERENCES email_provider (id) ON DELETE RESTRICT;
|
||||
CREATE INDEX email_message_from_address ON email_message (from_address_id);
|
||||
CREATE INDEX email_message_template ON email_message (template_id);
|
||||
CREATE INDEX email_message_template_version ON email_message (template_version_id);
|
||||
CREATE INDEX email_message_brand ON email_message (brand_id);
|
||||
CREATE INDEX email_template_brand ON email_template (brand_id);
|
||||
CREATE INDEX email_template_active_version ON email_template (active_version_id);
|
||||
CREATE INDEX email_template_approved_version ON email_template (approved_version_id);
|
||||
CREATE INDEX email_suppression_source_message ON email_suppression (source_message_id);
|
||||
CREATE INDEX email_suppression_source_event ON email_suppression (source_event_id);
|
||||
CREATE INDEX email_event_provider ON email_event (provider_id);
|
||||
@@ -134,6 +134,26 @@
|
||||
<version>${h2.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- the foreign key guardrail test: docs/ddl on a real PostgreSQL (src/test only) -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- EmailDefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2. -->
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.33.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -148,7 +168,35 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-email.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-email.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -168,7 +216,7 @@
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -241,7 +289,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -46,6 +46,7 @@ public class EmailQBitConfig implements QBitConfig
|
||||
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;
|
||||
@@ -58,12 +59,30 @@ public class EmailQBitConfig implements QBitConfig
|
||||
private Integer leaseSeconds = 120;
|
||||
private Integer bodyRetentionDays = 30;
|
||||
private Integer messageRetentionDays = 0;
|
||||
private String templatePermissionBaseName;
|
||||
private String appName = "email";
|
||||
private String appLabel = "Email";
|
||||
private List<QFieldMetaData> securityFields = List.of();
|
||||
private List<RecordSecurityLock> recordSecurityLocks = List.of();
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host permission base for template reads/writes only.
|
||||
*******************************************************************************/
|
||||
public String getTemplatePermissionBaseName()
|
||||
{
|
||||
return (templatePermissionBaseName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Grant no provider, delivery, PHI approval or other email settings access.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withTemplatePermissionBaseName(String templatePermissionBaseName)
|
||||
{
|
||||
this.templatePermissionBaseName = templatePermissionBaseName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -89,6 +108,11 @@ public class EmailQBitConfig implements QBitConfig
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(webhookBasePath == null || !webhookBasePath.matches("/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+")
|
||||
|| webhookBasePath.equals("/qqq/v1") || webhookBasePath.startsWith("/qqq/v1/"))
|
||||
{
|
||||
errors.add("webhookBasePath must be an absolute path outside /qqq/v1, without a trailing slash");
|
||||
}
|
||||
if(backendName == null || backendName.isBlank())
|
||||
{
|
||||
errors.add("backendName is required");
|
||||
@@ -201,6 +225,23 @@ public class EmailQBitConfig implements QBitConfig
|
||||
return (publicBaseUrl);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public provider and unsubscribe route base.
|
||||
*******************************************************************************/
|
||||
public String getWebhookBasePath()
|
||||
{
|
||||
return (webhookBasePath);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the route base and queue-time unsubscribe URLs together.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withWebhookBasePath(String webhookBasePath)
|
||||
{
|
||||
this.webhookBasePath = webhookBasePath;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publicBaseUrl.
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.email.delivery.EmailAlerts;
|
||||
import com.kingsrook.qbits.email.health.EmailHealthChecks;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
@@ -38,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
|
||||
@@ -51,13 +56,41 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-email";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private EmailQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = EmailQBitProducer.class.getClassLoader().getResourceAsStream("qbit-email.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-email.properties; build with Maven to generate the artifact version");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-email.properties; build with Maven");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read artifact version from qbit-email.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -143,6 +176,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
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)
|
||||
@@ -195,6 +229,31 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
output.add(EmailApp.app(qBitConfig, tables));
|
||||
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
output.addSelfToInstance(qInstance);
|
||||
EmailHealthChecks.register(qInstance);
|
||||
// Registration is metadata-only; core's startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-email.yaml");
|
||||
HelpBundles.of(qInstance).getBundles().stream().filter(bundle -> "qbit-email".equals(bundle.getId()))
|
||||
.flatMap(bundle -> bundle.getEntries().stream()).filter(entry -> "app:email;slot:help".equals(entry.getKey()))
|
||||
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
|
||||
registerWebhooks(qInstance);
|
||||
com.kingsrook.qbits.email.outbox.EmailJobEffectTarget.register(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailEffects;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.support.EmailApiMapper;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.template.CrmLegacyTemplateRenderer;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplates;
|
||||
import com.kingsrook.qbits.email.template.VariableSchema;
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checked single-recipient template handoff for CRM history. Content is returned
|
||||
** only after the stored envelope matches the caller-authorized template/version.
|
||||
** Existing metadata-only APIs deliberately remain unchanged.
|
||||
*******************************************************************************/
|
||||
public static EmailApiTemplateEffectResult sendTemplateOnce(Integer templateId, EmailApiRequest request, String effectKey) throws QException
|
||||
{
|
||||
if(request == null || request.getTo().size() != 1 || !request.getCc().isEmpty() || !request.getBcc().isEmpty()
|
||||
|| request.getSenderQbit() == null || request.getSenderProcess() == null
|
||||
|| request.getLinkedTableName() == null || request.getLinkedRecordId() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires one recipient, sender and linked record"));
|
||||
}
|
||||
List<String> recipients;
|
||||
try
|
||||
{
|
||||
recipients = EmailService.addresses(request.getTo());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires a valid recipient", e));
|
||||
}
|
||||
if(recipients.size() != 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires exactly one mailbox"));
|
||||
}
|
||||
request.withTo(recipients);
|
||||
EmailApiRenderedTemplate selected = renderTemplate(templateId, request);
|
||||
request.withTemplateName(selected.templateName());
|
||||
EmailApiEffectResult effect = sendOnce(request, effectKey);
|
||||
if(effect.outcome() == EmailApiEffectOutcome.REFUSED || effect.message() == null)
|
||||
{
|
||||
return (new EmailApiTemplateEffectResult(effect, null));
|
||||
}
|
||||
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", effect.message().id()).withLimit(1),
|
||||
Set.of("templateId", "templateVersionId", "senderQbit", "senderProcess", "linkedTableName", "linkedRecordId", "category",
|
||||
"toAddresses", "ccAddresses", "bccAddresses", "suppressedAddresses", "subject"));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QException("Checked template delivery is unavailable"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
if(!java.util.Objects.equals(selected.templateId(), row.getValueInteger("templateId"))
|
||||
|| !java.util.Objects.equals(selected.versionId(), row.getValueInteger("templateVersionId"))
|
||||
|| !java.util.Objects.equals(request.getSenderQbit(), row.getValueString("senderQbit"))
|
||||
|| !java.util.Objects.equals(request.getSenderProcess(), row.getValueString("senderProcess"))
|
||||
|| !java.util.Objects.equals(request.getLinkedTableName(), row.getValueString("linkedTableName"))
|
||||
|| !java.util.Objects.equals(request.getLinkedRecordId(), row.getValueString("linkedRecordId"))
|
||||
|| !java.util.Objects.equals(request.getCategory().name(), row.getValueString("category"))
|
||||
|| !java.util.Objects.equals(request.getTo().getFirst(), row.getValueString("toAddresses"))
|
||||
|| !blank(row.getValueString("ccAddresses")) || !blank(row.getValueString("bccAddresses")) || !blank(row.getValueString("suppressedAddresses")))
|
||||
{
|
||||
throw (new QException("Checked template delivery envelope does not match"));
|
||||
}
|
||||
return (new EmailApiTemplateEffectResult(effect, row.getValueString("subject")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional empty recipient columns are equivalent.
|
||||
*******************************************************************************/
|
||||
private static boolean blank(String value)
|
||||
{
|
||||
return (value == null || value.isBlank());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render as the caller: template and version reads enforce table/record locks.
|
||||
** Native kinds retain their category and classification policy. No PHI export.
|
||||
*******************************************************************************/
|
||||
public static EmailApiRenderedTemplate renderTemplate(Integer templateId, EmailApiRequest request) throws QException
|
||||
{
|
||||
if(templateId == null || request == null || request.getCategory() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template and expected delivery category are required"));
|
||||
}
|
||||
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
|
||||
if(Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
|
||||
{
|
||||
throw (new IllegalArgumentException("PHI-approved templates require the protected template send path"));
|
||||
}
|
||||
QRecord version = visibleActiveVersion(templateId, request.getLocale());
|
||||
QRecord rendered;
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
if(request.getCategory() != EmailApiCategory.TRANSACTIONAL && request.getCategory() != EmailApiCategory.MARKETING)
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
|
||||
}
|
||||
rendered = CrmLegacyTemplateRenderer.render(template, version, request.getVariables(), request.getSubject(), request.getMarkdownBody());
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!Set.of("MARKDOWN", "HTML", "MJML").contains(template.getValueString("kind")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Unsupported template kind"));
|
||||
}
|
||||
if(request.getSubject() != null || request.getMarkdownBody() != null || request.getHtmlBody() != null || request.getTextBody() != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Native template content cannot be overridden"));
|
||||
}
|
||||
var renderer = new EmailTemplateRenderer(template, version, EmailApiMapper.request(request));
|
||||
rendered = renderer.renderContent("render", "");
|
||||
}
|
||||
return (new EmailApiRenderedTemplate(templateId, version.getValueInteger("id"), template.getValueString("name"), template.getValueString("kind"), rendered.getValueString("subject"),
|
||||
rendered.getValueString("markdownBody"), rendered.getValueString("htmlBody"), rendered.getValueString("textBody")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs without exposing content; CRM only computes values in use.
|
||||
*******************************************************************************/
|
||||
public static Set<String> templateVariableNames(Integer templateId) throws QException
|
||||
{
|
||||
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
|
||||
QRecord version = visibleActiveVersion(templateId, "en");
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
String source = String.valueOf(version.getValue("subject")) + version.getValue("bodySource") + version.getValue("textSource");
|
||||
return (Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
|
||||
.filter(name -> source.contains("{{" + name + "}}")).collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
}
|
||||
return (new VariableSchema(version.getValueString("variablesSchema")).entries().keySet());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read only versions visible to the caller, preferring locale then English.
|
||||
*******************************************************************************/
|
||||
private static QRecord visibleActiveVersion(Integer templateId, String requestedLocale) throws QException
|
||||
{
|
||||
String locale = requestedLocale == null ? "en" : requestedLocale;
|
||||
QueryInput versionQuery = new QueryInput(EmailTables.VERSION).withShouldFetchHeavyFields(true)
|
||||
.withFilter(EmailStore.equal("templateId", templateId).withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
|
||||
new EmailPermissionChecker().checkPermissionsThrowing(versionQuery, QContext.getQInstance().getTable(EmailTables.VERSION));
|
||||
List<QRecord> versions = new QueryAction().execute(versionQuery).getRecords();
|
||||
QRecord version = versions.stream().filter(row -> locale.equals(row.getValueString("locale"))).findFirst()
|
||||
.orElseGet(() -> versions.stream().filter(row -> "en".equals(row.getValueString("locale"))).findFirst().orElse(null));
|
||||
if(version == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template has no visible active version"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary query actions deliberately avoid the email system-write scope.
|
||||
*******************************************************************************/
|
||||
private static QRecord visibleTemplateRecord(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput query = new QueryInput(table).withShouldFetchHeavyFields(true).withFilter(filter);
|
||||
new EmailPermissionChecker().checkPermissionsThrowing(query, QContext.getQInstance().getTable(table));
|
||||
List<QRecord> rows = new QueryAction().execute(query).getRecords();
|
||||
if(rows.size() != 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template is unavailable"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a validated template and active version once. Returns false when
|
||||
** it already exists. Invalid definitions throw IllegalArgumentException.
|
||||
** Does not grant PHI approval or overwrite administrator edits.
|
||||
*******************************************************************************/
|
||||
public static boolean ensureTemplate(EmailApiTemplate template) throws QException
|
||||
{
|
||||
return (EmailTemplates.ensure(EmailApiMapper.template(template)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System lookup of delivery metadata. Null and unknown ids return empty.
|
||||
*******************************************************************************/
|
||||
public static Optional<EmailApiMessage> getMessage(Integer messageId) throws QException
|
||||
{
|
||||
if(messageId == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", messageId).withLimit(1), MESSAGE_FIELDS);
|
||||
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System lookup by exact effect or application key. Null, blank and unknown
|
||||
** keys return empty. Returns current metadata without message content.
|
||||
*******************************************************************************/
|
||||
public static Optional<EmailApiMessage> getMessageByIdempotencyKey(String key) throws QException
|
||||
{
|
||||
return (findMessage("idempotencyKey", key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System lookup by the exact bracketed RFC Message-ID stored at enqueue time.
|
||||
** Null, blank and unknown ids return empty; provider response ids are distinct.
|
||||
*******************************************************************************/
|
||||
public static Optional<EmailApiMessage> getMessageByRfcMessageId(String rfcMessageId) throws QException
|
||||
{
|
||||
return (findMessage("rfcMessageId", rfcMessageId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share exact-key lookup semantics and the metadata-only projection.
|
||||
*******************************************************************************/
|
||||
private static Optional<EmailApiMessage> findMessage(String field, String value) throws QException
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal(field, value).withLimit(1), MESSAGE_FIELDS);
|
||||
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System lookup by literal prefix, ordered by ascending message id. Prefix
|
||||
** must be nonblank and limit positive; invalid arguments are rejected.
|
||||
** Returns at most limit rows, without message content or recipient data.
|
||||
*******************************************************************************/
|
||||
public static List<EmailApiMessage> findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException
|
||||
{
|
||||
if(prefix == null || prefix.isBlank() || limit < 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("A nonblank idempotency key prefix and a positive limit are required"));
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// QQQ SQL STARTS_WITH uses LIKE without escaping. Narrow by the literal //
|
||||
// portion before any wildcard, then check the full prefix in Java. Page //
|
||||
// by id so wildcard false positives cannot consume the requested limit. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
String candidatePrefix = prefix.split("[%_\\\\]", 2)[0];
|
||||
List<EmailApiMessage> messages = new ArrayList<>();
|
||||
int lastId = 0;
|
||||
while(messages.size() < limit)
|
||||
{
|
||||
int batchSize = Math.min(500, limit - messages.size());
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, candidatePrefix))
|
||||
.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(batchSize);
|
||||
List<QRecord> rows = read(EmailTables.MESSAGE, filter, MESSAGE_FIELDS);
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
lastId = row.getValueInteger("id");
|
||||
if(row.getValueString("idempotencyKey") != null && row.getValueString("idempotencyKey").startsWith(prefix))
|
||||
{
|
||||
messages.add(message(row));
|
||||
}
|
||||
}
|
||||
if(rows.size() < batchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (List.copyOf(messages));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled sender rows of an enabled named provider, ordered by id. Null
|
||||
** selects the effective default provider. No match returns an empty list.
|
||||
** The provider's legacy from-address fallback is not a sender row.
|
||||
*******************************************************************************/
|
||||
public static List<EmailApiSenderAddress> listSenderAddresses(String providerName) throws QException
|
||||
{
|
||||
QRecord provider = provider(QContext.getQInstance(), providerName);
|
||||
if(provider == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
QQueryFilter filter = EmailStore.equal("providerId", provider.getValueInteger("id"))
|
||||
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id"));
|
||||
return (read(EmailTables.SENDER, filter, Set.of("id", "address", "displayName", "isDefault"))
|
||||
.stream().map(row -> new EmailApiSenderAddress(row.getValueInteger("id"), row.getValueString("address"),
|
||||
row.getValueString("displayName"), Boolean.TRUE.equals(row.getValueBoolean("isDefault")))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective enabled default: the configured name takes precedence over the
|
||||
** default row. Empty when absent, disabled or ambiguous. An installed
|
||||
** instance must be the current QContext instance, otherwise this throws.
|
||||
*******************************************************************************/
|
||||
public static Optional<String> defaultProviderName(QInstance instance) throws QException
|
||||
{
|
||||
QRecord provider = provider(instance, null);
|
||||
return (provider == null ? Optional.empty() : Optional.of(provider.getValueString("name")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preflight the default sending configuration for a category. Null category
|
||||
** means TRANSACTIONAL. This does not validate a particular template,
|
||||
** recipient or permission, or contact a transport. PHI callers use the
|
||||
** overload with phiApproved set to true.
|
||||
*******************************************************************************/
|
||||
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category) throws QException
|
||||
{
|
||||
return (readiness(instance, category, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Also check prerequisites for a PHI-approved template. PHI is a template
|
||||
** property, not an email category. Checks body key availability and provider
|
||||
** PHI/tracking policy; does not approve a template. Resolver failures are
|
||||
** reported as missing keys without exposing exception details or values.
|
||||
*******************************************************************************/
|
||||
public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category, boolean phiApproved) throws QException
|
||||
{
|
||||
List<String> reasons = new ArrayList<>();
|
||||
Set<EmailApiReadinessCheck> failed = EnumSet.noneOf(EmailApiReadinessCheck.class);
|
||||
var config = EmailRuntime.config(instance);
|
||||
if(config == null)
|
||||
{
|
||||
return (new EmailApiReadiness(false, List.of("Email qbit is not installed"), Set.of(EmailApiReadinessCheck.NOT_INSTALLED)));
|
||||
}
|
||||
QRecord provider = provider(instance, null);
|
||||
if(provider == null)
|
||||
{
|
||||
reasons.add("An enabled named or default provider is required");
|
||||
failed.add(EmailApiReadinessCheck.PROVIDER_MISSING);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!EmailTableCustomizers.validateProvider(provider, config).isEmpty())
|
||||
{
|
||||
reasons.add("The default provider configuration is invalid");
|
||||
failed.add(EmailApiReadinessCheck.PROVIDER_INVALID);
|
||||
}
|
||||
if(phiApproved)
|
||||
{
|
||||
try
|
||||
{
|
||||
EmailTemplateRenderer.checkProvider(provider);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
reasons.add("PHI email needs a PHI-enabled provider with open and click tracking disabled");
|
||||
failed.add(EmailApiReadinessCheck.PHI_PROVIDER);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(category == EmailApiCategory.MARKETING)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
VariableSchema.url(config.getPublicBaseUrl());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
reasons.add("MARKETING email needs a valid publicBaseUrl");
|
||||
failed.add(EmailApiReadinessCheck.PUBLIC_BASE_URL);
|
||||
}
|
||||
if(!hasSecret(config.getSecretResolver(), "QQQ_EMAIL_UNSUBSCRIBE_KEY"))
|
||||
{
|
||||
reasons.add("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
failed.add(EmailApiReadinessCheck.UNSUBSCRIBE_KEY);
|
||||
}
|
||||
List<QRecord> brands = read(EmailTables.BRAND, EmailStore.equal("isDefault", true).withLimit(2), Set.of("id", "postalAddress"));
|
||||
if(brands.size() != 1 || brands.getFirst().getValueString("postalAddress") == null || brands.getFirst().getValueString("postalAddress").isBlank())
|
||||
{
|
||||
reasons.add("MARKETING email needs a default brand with a postal address");
|
||||
failed.add(EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS);
|
||||
}
|
||||
}
|
||||
if(phiApproved && !hasSecret(config.getSecretResolver(), "QQQ_EMAIL_BODY_KEY"))
|
||||
{
|
||||
reasons.add("PHI email needs QQQ_EMAIL_BODY_KEY");
|
||||
failed.add(EmailApiReadinessCheck.BODY_KEY);
|
||||
}
|
||||
return (new EmailApiReadiness(reasons.isEmpty(), reasons, failed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence only; neither retain values nor expose resolver failures.
|
||||
*******************************************************************************/
|
||||
private static boolean hasSecret(Function<String, String> resolver, String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
String value = resolver.apply(name);
|
||||
return (value != null && !value.isBlank());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Match the enqueue service's configured-name/default-row precedence.
|
||||
** Refuse a mismatched context instead of reading another instance's data.
|
||||
*******************************************************************************/
|
||||
private static QRecord provider(QInstance instance, String name) throws QException
|
||||
{
|
||||
var config = EmailRuntime.config(instance);
|
||||
if(config == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(instance != QContext.getQInstance())
|
||||
{
|
||||
throw (new IllegalArgumentException("The supplied instance must be the current QContext instance"));
|
||||
}
|
||||
String effectiveName = name == null ? config.getDefaultProviderName() : name;
|
||||
QQueryFilter filter = EmailStore.equal("enabled", true)
|
||||
.withCriteria(new QFilterCriteria(effectiveName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, effectiveName == null ? true : effectiveName))
|
||||
.withLimit(2);
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, filter);
|
||||
return (providers.size() == 1 ? providers.getFirst() : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read an explicit projection; message bodies are never fetched here.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> read(String table, QQueryFilter filter, Set<String> fields) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)
|
||||
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the template name and copy only the approved metadata fields.
|
||||
*******************************************************************************/
|
||||
private static EmailApiMessage message(QRecord row) throws QException
|
||||
{
|
||||
String templateName = null;
|
||||
if(row.getValueInteger("templateId") != null)
|
||||
{
|
||||
List<QRecord> templates = read(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValueInteger("templateId")).withLimit(1), Set.of("id", "name"));
|
||||
if(!templates.isEmpty())
|
||||
{
|
||||
templateName = templates.getFirst().getValueString("name");
|
||||
}
|
||||
}
|
||||
String category = row.getValueString("category");
|
||||
return (new EmailApiMessage(row.getValueInteger("id"), row.getValueString("status"), category == null ? null : EmailApiCategory.valueOf(category),
|
||||
row.getValueString("idempotencyKey"), row.getValueString("rfcMessageId"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"),
|
||||
row.getValueString("linkedRecordId"), row.getValueInstant("createDate"), row.getValueInstant("sentDate"), row.getValueInstant("deliveredDate")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable sending categories; PHI approval belongs to the template, not the category.
|
||||
*******************************************************************************/
|
||||
public enum EmailApiCategory
|
||||
{
|
||||
TRANSACTIONAL,
|
||||
NOTIFICATION,
|
||||
MARKETING,
|
||||
SECURITY
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable handoff outcomes. Operational failures propagate to the caller.
|
||||
*******************************************************************************/
|
||||
public enum EmailApiEffectOutcome
|
||||
{
|
||||
QUEUED_OR_DUPLICATE,
|
||||
REFUSED
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable handoff result. The message is null when no outbox row was retained.
|
||||
** Suppressed audit rows carry metadata even though the outcome is REFUSED.
|
||||
*******************************************************************************/
|
||||
public record EmailApiEffectResult(EmailApiEffectOutcome outcome, EmailApiMessage message)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable delivery metadata. No addresses, subject, bodies or variable snapshots.
|
||||
** Status is the persisted delivery state, distinct from EmailApiStatus enqueue outcomes.
|
||||
*******************************************************************************/
|
||||
public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String rfcMessageId, String providerMessageId,
|
||||
String templateName, String linkedTableName, String linkedRecordId, Instant createDate, Instant sentDate, Instant deliveredDate)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration preflight, not a delivery guarantee. Reasons never contain secret values.
|
||||
*******************************************************************************/
|
||||
public record EmailApiReadiness(boolean ready, List<String> reasons, Set<EmailApiReadinessCheck> failedChecks)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Copy configuration reasons.
|
||||
*******************************************************************************/
|
||||
public EmailApiReadiness
|
||||
{
|
||||
reasons = List.copyOf(reasons);
|
||||
failedChecks = failedChecks == null || failedChecks.isEmpty() ? Set.of() : Set.copyOf(EnumSet.copyOf(failedChecks));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a specific prerequisite failed.
|
||||
*******************************************************************************/
|
||||
public boolean failed(EmailApiReadinessCheck check)
|
||||
{
|
||||
return (failedChecks.contains(check));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One readiness prerequisite, so callers can react to specific gaps without
|
||||
** parsing reason text.
|
||||
*******************************************************************************/
|
||||
public enum EmailApiReadinessCheck
|
||||
{
|
||||
NOT_INSTALLED,
|
||||
PROVIDER_MISSING,
|
||||
PROVIDER_INVALID,
|
||||
PHI_PROVIDER,
|
||||
PUBLIC_BASE_URL,
|
||||
UNSUBSCRIBE_KEY,
|
||||
BRAND_POSTAL_ADDRESS,
|
||||
BODY_KEY
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed content and provenance. HTML must never be reinterpreted as Markdown.
|
||||
*******************************************************************************/
|
||||
public record EmailApiRenderedTemplate(Integer templateId, Integer versionId, String templateName, String kind, String subject,
|
||||
String markdownBody, String htmlBody, String textBody)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable enqueue request. One send always goes through the outbox.
|
||||
** Null category means TRANSACTIONAL for ad-hoc mail; templates select their category.
|
||||
** Recipient lists and variable maps are mutable request-building state.
|
||||
*******************************************************************************/
|
||||
public final class EmailApiRequest
|
||||
{
|
||||
private List<String> to = new ArrayList<>();
|
||||
private List<String> cc = new ArrayList<>();
|
||||
private List<String> bcc = new ArrayList<>();
|
||||
private String from;
|
||||
private Integer fromAddressId;
|
||||
private String replyTo;
|
||||
private String subject;
|
||||
private String markdownBody;
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
private String providerName;
|
||||
private String templateName;
|
||||
private String brandName;
|
||||
private String locale = "en";
|
||||
private EmailApiCategory category;
|
||||
private String idempotencyKey;
|
||||
private QBackendTransaction transaction;
|
||||
private String transactionBackendName;
|
||||
private Instant sendAfter;
|
||||
private String linkedTableName;
|
||||
private String linkedRecordId;
|
||||
private String senderQbit;
|
||||
private String senderProcess;
|
||||
private Map<String, Serializable> variables = new LinkedHashMap<>();
|
||||
private Map<String, RecordVariable> recordVariables = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for to.
|
||||
*******************************************************************************/
|
||||
public List<String> getTo()
|
||||
{
|
||||
return (to);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for to.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withTo(List<String> to)
|
||||
{
|
||||
this.to = to;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cc.
|
||||
*******************************************************************************/
|
||||
public List<String> getCc()
|
||||
{
|
||||
return (cc);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cc.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withCc(List<String> cc)
|
||||
{
|
||||
this.cc = cc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bcc.
|
||||
*******************************************************************************/
|
||||
public List<String> getBcc()
|
||||
{
|
||||
return (bcc);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bcc.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withBcc(List<String> bcc)
|
||||
{
|
||||
this.bcc = bcc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for from.
|
||||
*******************************************************************************/
|
||||
public String getFrom()
|
||||
{
|
||||
return (from);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for from.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withFrom(String from)
|
||||
{
|
||||
this.from = from;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fromAddressId.
|
||||
*******************************************************************************/
|
||||
public Integer getFromAddressId()
|
||||
{
|
||||
return (fromAddressId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fromAddressId.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withFromAddressId(Integer fromAddressId)
|
||||
{
|
||||
this.fromAddressId = fromAddressId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for replyTo.
|
||||
*******************************************************************************/
|
||||
public String getReplyTo()
|
||||
{
|
||||
return (replyTo);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for replyTo.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withReplyTo(String replyTo)
|
||||
{
|
||||
this.replyTo = replyTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subject.
|
||||
*******************************************************************************/
|
||||
public String getSubject()
|
||||
{
|
||||
return (subject);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subject.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withSubject(String subject)
|
||||
{
|
||||
this.subject = subject;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for markdownBody.
|
||||
*******************************************************************************/
|
||||
public String getMarkdownBody()
|
||||
{
|
||||
return (markdownBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for markdownBody.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withMarkdownBody(String markdownBody)
|
||||
{
|
||||
this.markdownBody = markdownBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public String getHtmlBody()
|
||||
{
|
||||
return (htmlBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withHtmlBody(String htmlBody)
|
||||
{
|
||||
this.htmlBody = htmlBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for textBody.
|
||||
*******************************************************************************/
|
||||
public String getTextBody()
|
||||
{
|
||||
return (textBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for textBody.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withTextBody(String textBody)
|
||||
{
|
||||
this.textBody = textBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerName.
|
||||
*******************************************************************************/
|
||||
public String getProviderName()
|
||||
{
|
||||
return (providerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerName.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withProviderName(String providerName)
|
||||
{
|
||||
this.providerName = providerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for templateName.
|
||||
*******************************************************************************/
|
||||
public String getTemplateName()
|
||||
{
|
||||
return (templateName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for templateName.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withTemplateName(String templateName)
|
||||
{
|
||||
this.templateName = templateName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for brandName.
|
||||
*******************************************************************************/
|
||||
public String getBrandName()
|
||||
{
|
||||
return (brandName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for brandName.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withBrandName(String brandName)
|
||||
{
|
||||
this.brandName = brandName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for locale.
|
||||
*******************************************************************************/
|
||||
public String getLocale()
|
||||
{
|
||||
return (locale);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for locale.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withLocale(String locale)
|
||||
{
|
||||
this.locale = locale;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category.
|
||||
*******************************************************************************/
|
||||
public EmailApiCategory getCategory()
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withCategory(EmailApiCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return (idempotencyKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the optional caller transaction. sendOnce refuses this option.
|
||||
*******************************************************************************/
|
||||
public QBackendTransaction getTransaction()
|
||||
{
|
||||
return (transaction);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the declared backend of the caller transaction.
|
||||
*******************************************************************************/
|
||||
public String getTransactionBackendName()
|
||||
{
|
||||
return (transactionBackendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Join a caller transaction for ordinary send; another backend defers enqueue
|
||||
** until commit. Declare its backend because QBackendTransaction does not expose
|
||||
** it. sendOnce refuses any caller transaction to guarantee committed handoff.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withTransaction(QBackendTransaction transaction, String backendName)
|
||||
{
|
||||
this.transaction = transaction;
|
||||
this.transactionBackendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sendAfter.
|
||||
*******************************************************************************/
|
||||
public Instant getSendAfter()
|
||||
{
|
||||
return (sendAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sendAfter.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withSendAfter(Instant sendAfter)
|
||||
{
|
||||
this.sendAfter = sendAfter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkedTableName.
|
||||
*******************************************************************************/
|
||||
public String getLinkedTableName()
|
||||
{
|
||||
return (linkedTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkedTableName.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withLinkedTableName(String linkedTableName)
|
||||
{
|
||||
this.linkedTableName = linkedTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkedRecordId.
|
||||
*******************************************************************************/
|
||||
public String getLinkedRecordId()
|
||||
{
|
||||
return (linkedRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkedRecordId.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withLinkedRecordId(String linkedRecordId)
|
||||
{
|
||||
this.linkedRecordId = linkedRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for senderQbit.
|
||||
*******************************************************************************/
|
||||
public String getSenderQbit()
|
||||
{
|
||||
return (senderQbit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for senderQbit.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withSenderQbit(String senderQbit)
|
||||
{
|
||||
this.senderQbit = senderQbit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for senderProcess.
|
||||
*******************************************************************************/
|
||||
public String getSenderProcess()
|
||||
{
|
||||
return (senderProcess);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for senderProcess.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withSenderProcess(String senderProcess)
|
||||
{
|
||||
this.senderProcess = senderProcess;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for variables.
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getVariables()
|
||||
{
|
||||
return (variables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for variables.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withVariables(Map<String, Serializable> variables)
|
||||
{
|
||||
this.variables = variables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordVariables.
|
||||
*******************************************************************************/
|
||||
public Map<String, RecordVariable> getRecordVariables()
|
||||
{
|
||||
return (recordVariables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordVariables.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withRecordVariables(Map<String, RecordVariable> recordVariables)
|
||||
{
|
||||
this.recordVariables = recordVariables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append to recipients. Sending still enforces category and PHI recipient rules.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withTo(String... addresses)
|
||||
{
|
||||
to.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append cc recipients. Sending still enforces category and PHI recipient rules.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withCc(String... addresses)
|
||||
{
|
||||
cc.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append bcc recipients. Sending still enforces category and PHI recipient rules.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withBcc(String... addresses)
|
||||
{
|
||||
bcc.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link the message to an application record.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withLinkedRecord(String table, Object id)
|
||||
{
|
||||
linkedTableName = table;
|
||||
linkedRecordId = id == null ? null : String.valueOf(id);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identify the producing qbit and process.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withSender(String qbit, String process)
|
||||
{
|
||||
senderQbit = qbit;
|
||||
senderProcess = process;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind one variable; template schema and classification checks still apply.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withVariable(String name, Serializable value)
|
||||
{
|
||||
variables.put(name, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind record provenance without reading or snapshotting the record.
|
||||
*******************************************************************************/
|
||||
public EmailApiRequest withRecordVariable(String name, String table, Serializable pk)
|
||||
{
|
||||
recordVariables.put(name, new RecordVariable(table, pk));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record reference; values are resolved and classified by the sending service.
|
||||
*******************************************************************************/
|
||||
public record RecordVariable(String table, Serializable id)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable enqueue result with plain refusal reasons.
|
||||
*******************************************************************************/
|
||||
public record EmailApiResult(Integer messageId, EmailApiStatus status, List<String> reasons)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Copy reasons so callers cannot mutate an outcome.
|
||||
*******************************************************************************/
|
||||
public EmailApiResult
|
||||
{
|
||||
reasons = List.copyOf(reasons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An enabled provider sender address; no provider credentials.
|
||||
*******************************************************************************/
|
||||
public record EmailApiSenderAddress(Integer id, String address, String displayName, boolean isDefault)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue outcomes. QUEUED acknowledges storage, not delivery.
|
||||
*******************************************************************************/
|
||||
public enum EmailApiStatus
|
||||
{
|
||||
QUEUED,
|
||||
DUPLICATE,
|
||||
SUPPRESSED,
|
||||
REFUSED
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A create-once template definition. Existing templates and versions are never overwritten.
|
||||
** New templates are not PHI approved; approval remains a separately authorized operation.
|
||||
*******************************************************************************/
|
||||
public final class EmailApiTemplate
|
||||
{
|
||||
private String name;
|
||||
private String label;
|
||||
private EmailApiCategory category;
|
||||
private String kind = "MARKDOWN";
|
||||
private String subject;
|
||||
private String body;
|
||||
private String textBody;
|
||||
private String variablesSchema;
|
||||
private List<EmailApiVariable> variables = new ArrayList<>();
|
||||
private String sampleVariables;
|
||||
private String ownerQbit;
|
||||
private String editPermissionName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category.
|
||||
*******************************************************************************/
|
||||
public EmailApiCategory getCategory()
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withCategory(EmailApiCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind.
|
||||
*******************************************************************************/
|
||||
public String getKind()
|
||||
{
|
||||
return (kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subject.
|
||||
*******************************************************************************/
|
||||
public String getSubject()
|
||||
{
|
||||
return (subject);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subject.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withSubject(String subject)
|
||||
{
|
||||
this.subject = subject;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for body.
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for body.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for textBody.
|
||||
*******************************************************************************/
|
||||
public String getTextBody()
|
||||
{
|
||||
return (textBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for textBody.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withTextBody(String textBody)
|
||||
{
|
||||
this.textBody = textBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for variablesSchema.
|
||||
*******************************************************************************/
|
||||
public String getVariablesSchema()
|
||||
{
|
||||
return (variablesSchema);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for variablesSchema.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withVariablesSchema(String variablesSchema)
|
||||
{
|
||||
this.variablesSchema = variablesSchema;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for variables.
|
||||
*******************************************************************************/
|
||||
public List<EmailApiVariable> getVariables()
|
||||
{
|
||||
return (variables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for variables.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withVariables(List<EmailApiVariable> variables)
|
||||
{
|
||||
this.variables = variables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sampleVariables.
|
||||
*******************************************************************************/
|
||||
public String getSampleVariables()
|
||||
{
|
||||
return (sampleVariables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sampleVariables.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withSampleVariables(String sampleVariables)
|
||||
{
|
||||
this.sampleVariables = sampleVariables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ownerQbit.
|
||||
*******************************************************************************/
|
||||
public String getOwnerQbit()
|
||||
{
|
||||
return (ownerQbit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ownerQbit.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withOwnerQbit(String ownerQbit)
|
||||
{
|
||||
this.ownerQbit = ownerQbit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for editPermissionName.
|
||||
*******************************************************************************/
|
||||
public String getEditPermissionName()
|
||||
{
|
||||
return (editPermissionName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for editPermissionName.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withEditPermissionName(String editPermissionName)
|
||||
{
|
||||
this.editPermissionName = editPermissionName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append a typed variable declaration. Use this or variablesSchema, not both.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withVariable(EmailApiVariable variable)
|
||||
{
|
||||
variables.add(variable);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append a variable declaration using the template schema vocabulary.
|
||||
*******************************************************************************/
|
||||
public EmailApiTemplate withVariable(String name, String label, String type, boolean required, String classification)
|
||||
{
|
||||
return (withVariable(new EmailApiVariable(name, label, type, required, classification)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact stored subject for a checked template envelope, never an arbitrary lookup.
|
||||
*******************************************************************************/
|
||||
public record EmailApiTemplateEffectResult(EmailApiEffectResult effect, String subject)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Template variable declaration. Types: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN,
|
||||
** URL, LIST, RECORD. Classification: PUBLIC, CONFIDENTIAL, PII, PHI or null.
|
||||
*******************************************************************************/
|
||||
public record EmailApiVariable(String name, String label, String type, boolean required, String classification)
|
||||
{
|
||||
}
|
||||
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.email.app;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.health.EmailHealthWidget;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -64,6 +65,7 @@ public final class EmailApp
|
||||
{
|
||||
return (List.of(
|
||||
widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class),
|
||||
widget("emailHealth", "Email health", WidgetType.STATISTICS.getType(), EmailHealthWidget.class),
|
||||
widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class)));
|
||||
|
||||
}
|
||||
@@ -80,7 +82,7 @@ public final class EmailApp
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("mail"))
|
||||
.withPermissionRules(EmailTables.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET));
|
||||
.withWidgets(List.of("emailHealth", HOME_WIDGET));
|
||||
for(String name : EmailTables.MAIN)
|
||||
{
|
||||
if(tables.containsKey(name))
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.template.EmailBuiltIns;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -68,27 +69,33 @@ public final class EmailHome
|
||||
String[] fields = {"sentDate", "deliveredDate", "deadLetterDate", "bouncedDate", "complainedDate"};
|
||||
for(int i = 0; i < fields.length; i++)
|
||||
{
|
||||
totals.add(count(EmailTables.MESSAGE, since(fields[i], now.minus(i < 3 ? 1 : 7, ChronoUnit.DAYS))
|
||||
.withCriteria(fields[i], QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
|
||||
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)
|
||||
List<EmailSetupStatus.Item> setup = List.of();
|
||||
if(admin)
|
||||
{
|
||||
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));
|
||||
}
|
||||
EmailBuiltIns.ensure();
|
||||
setup = EmailSetupStatus.load();
|
||||
}
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions())));
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), messages, suppressions(), setup)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -42,8 +42,8 @@ public final class EmailHomeView
|
||||
private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box;";
|
||||
private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;";
|
||||
private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);";
|
||||
private static final String FAIL = "#d64545";
|
||||
private static final String OK = "#2e9e5b";
|
||||
private static final String FAIL = "#d64545";
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -60,9 +60,20 @@ public final class EmailHomeView
|
||||
/*******************************************************************************
|
||||
** One ordered home with real, viewer-scoped data supplied by EmailHome.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions)
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> messages, ChartData suppressions)
|
||||
{
|
||||
return (render(totals, hourly, messages, suppressions, List.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same home with the Setup card first (email admins), when there are items.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> messages, ChartData suppressions, List<EmailSetupStatus.Item> setup)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
|
||||
setup(html, setup);
|
||||
html.append("<div style=\"" + WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr));gap:12px\">");
|
||||
List<String> labels = List.of("Sent (24 h)", "Delivered (24 h)", "Dead letters (24 h)", "Bounced (7 d)", "Complaints (7 d)", "Suppressed addresses");
|
||||
for(int i = 0; i < labels.size(); i++)
|
||||
@@ -74,7 +85,6 @@ public final class EmailHomeView
|
||||
}
|
||||
html.append("</div>");
|
||||
bars(html, "Sends per hour, last 24 hours", hourly);
|
||||
providers(html, providers, admin);
|
||||
messages(html, messages);
|
||||
bars(html, "Suppressions added, last 30 days", suppressions);
|
||||
return (html.append("</div>").toString());
|
||||
@@ -82,6 +92,46 @@ public final class EmailHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each part of Email, set up or not, and the next step, linked
|
||||
** to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, List<EmailSetupStatus.Item> items)
|
||||
{
|
||||
if(items.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
section(html, "Setup");
|
||||
long todo = items.stream().filter(item -> item.state() == EmailSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 8px;opacity:.75\">").append(todo == 0 ? "Everything Email needs is set up." : todo + (todo == 1 ? " item needs" : " items need") + " attention.").append("</p>");
|
||||
for(EmailSetupStatus.Item item : items)
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> OK;
|
||||
case TODO -> FAIL;
|
||||
case DEFAULT -> "#d97706";
|
||||
case OPTIONAL -> "#808080";
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
String name = item.tableName() == null ? esc(item.label()) : "<a style=\"color:inherit\" href=\"/app/" + esc(item.tableName()) + "\">" + esc(item.label()) + "</a>";
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Part", "<strong>" + name + "</strong><br><span style=\"color:" + color + "\">" + label + "</span>"))
|
||||
.append(cell("Status", esc(item.detail())))
|
||||
.append(cell("Next step", esc(item.nextStep() == null ? "Nothing to do" : item.nextStep()))).append("</div>");
|
||||
}
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded stacked bar chart, including exact values in each bar's title.
|
||||
*******************************************************************************/
|
||||
@@ -138,32 +188,6 @@ public final class EmailHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider health is admin-only; disabled providers never appear healthy.
|
||||
*******************************************************************************/
|
||||
private static void providers(StringBuilder html, List<QRecord> providers, boolean admin)
|
||||
{
|
||||
section(html, "Provider health");
|
||||
if(!admin || providers.isEmpty())
|
||||
{
|
||||
html.append("<p style=\"margin:0;opacity:.75\">").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("</p>");
|
||||
}
|
||||
for(QRecord provider : providers)
|
||||
{
|
||||
String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled"
|
||||
: "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown";
|
||||
String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080";
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Provider", "<strong>" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "</strong><br><span style=\"color:" + color + "\">" + status + "</span>"))
|
||||
.append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate")))))
|
||||
.append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None"))))
|
||||
.append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("</div>");
|
||||
}
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole row is a link. Labelled columns stack as cards on narrow screens.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadiness;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Email home: each configuration table, whether it is
|
||||
** set up, and the next step. It uses the same resolution the sender uses (the
|
||||
** default provider, the brand lookup, EmailApi.readiness), so it never reports
|
||||
** a problem the sender would not have.
|
||||
*******************************************************************************/
|
||||
public final class EmailSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
|
||||
** OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items (as the qbit, for an email admin).
|
||||
*******************************************************************************/
|
||||
public static List<Item> load() throws QException
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// the provider: the same check a send makes (readiness) //
|
||||
///////////////////////////////////////////////////////////
|
||||
EmailApiReadiness readiness = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL);
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter());
|
||||
QRecord active = providers.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("enabled")) && Boolean.TRUE.equals(p.getValueBoolean("isDefault")))
|
||||
.findFirst().orElse(null);
|
||||
if(readiness.failed(EmailApiReadinessCheck.PROVIDER_MISSING))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, providers.isEmpty() ? "No provider yet, so nothing can be sent." : "No enabled default provider, so nothing can be sent.",
|
||||
"Add one with New (kind, host or region, from address, and the NAMES of the secret variables), turn on Default, then Send Test Email."));
|
||||
}
|
||||
else if(readiness.failed(EmailApiReadinessCheck.PROVIDER_INVALID))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider's configuration is invalid.", "Open it and fix the highlighted settings, then Send Test Email."));
|
||||
}
|
||||
else if(active != null && "UNHEALTHY".equals(active.getValueString("healthStatus")))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing. See System Health for its status.",
|
||||
"Check its settings and secret variables, then Send Test Email."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.OK, valueOr(active == null ? null : active.getValueString("label"), "The default provider") + " sends mail.", null));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// sender addresses: the provider's own from address works //
|
||||
///////////////////////////////////////////////////////////////
|
||||
int senders = SystemWrites.count(EmailTables.SENDER, new QQueryFilter());
|
||||
items.add(senders == 0
|
||||
? new Item("Sender addresses", EmailTables.SENDER, State.OPTIONAL, "None: the provider's own from address is used.", "Add some if one provider sends as several mailboxes.")
|
||||
: new Item("Sender addresses", EmailTables.SENDER, State.OK, senders + (senders == 1 ? " address." : " addresses."), null));
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// brand: the lookup the renderer does (app brand, then default) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
List<QRecord> brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("appName", config.getAppName()));
|
||||
if(brands.isEmpty())
|
||||
{
|
||||
brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("isDefault", true));
|
||||
}
|
||||
if(brands.size() > 1)
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.TODO, "More than one default brand matches, so branded sends are refused.", "Leave Default Brand on only one."));
|
||||
}
|
||||
else if(brands.isEmpty())
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.DEFAULT, "No default brand: emails use the plain neutral look.", "Copy the neutral brand, set your logo, colors and footer, and tick Default Brand."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.OK, valueOr(brands.getFirst().getValueString("label"), "The default brand") + " is the default.", null));
|
||||
}
|
||||
|
||||
///////////////
|
||||
// templates //
|
||||
///////////////
|
||||
int templates = SystemWrites.count(EmailTables.TEMPLATE, new QQueryFilter());
|
||||
items.add(templates == 0
|
||||
? new Item("Templates", EmailTables.TEMPLATE, State.DEFAULT, "No template yet: plain messages can still be sent.", "Other qbits register theirs; add your own with New, then Activate Version.")
|
||||
: new Item("Templates", EmailTables.TEMPLATE, State.OK, templates + (templates == 1 ? " template." : " templates."), null));
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// marketing: the same checks as a MARKETING send //
|
||||
/////////////////////////////////////////////////////
|
||||
EmailApiReadiness marketing = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING);
|
||||
List<String> missing = new ArrayList<>();
|
||||
if(marketing.failed(EmailApiReadinessCheck.PUBLIC_BASE_URL))
|
||||
{
|
||||
missing.add("a public base URL");
|
||||
}
|
||||
if(marketing.failed(EmailApiReadinessCheck.UNSUBSCRIBE_KEY))
|
||||
{
|
||||
missing.add("the QQQ_EMAIL_UNSUBSCRIBE_KEY variable");
|
||||
}
|
||||
if(marketing.failed(EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS))
|
||||
{
|
||||
missing.add("a default brand with a postal address");
|
||||
}
|
||||
items.add(missing.isEmpty()
|
||||
? new Item("Marketing email", EmailTables.BRAND, State.OK, "Ready: unsubscribe links, base URL and postal address are set.", null)
|
||||
: new Item("Marketing email", EmailTables.BRAND, State.OPTIONAL, "Off until set up: needs " + String.join(", ", missing) + ".", "Only needed if you send marketing email."));
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// delivery tracking: the provider's webhook secret name //
|
||||
//////////////////////////////////////////////////////////
|
||||
boolean webhook = active != null && valueOr(active.getValueString("webhookSecretEnvName"), "").length() > 0;
|
||||
int inbox = SystemWrites.count(EmailTables.INBOX, new QQueryFilter());
|
||||
items.add(webhook
|
||||
? new Item("Delivery tracking", EmailTables.INBOX, State.OK, inbox == 0 ? "A webhook secret is set; no callback has arrived yet." : inbox + " provider callbacks received.", null)
|
||||
: new Item("Delivery tracking", EmailTables.INBOX, State.OPTIONAL, "Off: the default provider has no webhook secret variable, so bounces and complaints are not tracked.",
|
||||
"Set the provider's webhook secret variable and point the provider at " + config.getWebhookBasePath() + "."));
|
||||
|
||||
int suppressed = SystemWrites.count(EmailTables.SUPPRESSION, new QQueryFilter());
|
||||
items.add(new Item("Suppression list", EmailTables.SUPPRESSION, State.OPTIONAL, suppressed == 0 ? "No blocked addresses yet; bounces, complaints and unsubscribes add them." : suppressed + " blocked addresses.", null));
|
||||
|
||||
items.add(config.getSchedulerName() == null
|
||||
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so the outbox is only sent when someone runs Dispatch by hand.", "Set schedulerName in the Email config.")
|
||||
: new Item("Scheduler", null, State.OK, "Dispatch, webhooks, maintenance and retention run on the " + config.getSchedulerName() + " scheduler.", null));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value, or the fallback when blank.
|
||||
*******************************************************************************/
|
||||
private static String valueOr(String value, String fallback)
|
||||
{
|
||||
return (value == null || value.isBlank() ? fallback : value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
*******************************************************************************/
|
||||
public final class EmailAlerts
|
||||
{
|
||||
public static final String COMPLAINT = "email.complaint";
|
||||
public static final String UNHEALTHY = "email.provider.unhealthy";
|
||||
public static final String DEAD_LETTER = "email.message.dead-letter";
|
||||
|
||||
@@ -50,7 +51,9 @@ public final class EmailAlerts
|
||||
*******************************************************************************/
|
||||
public static List<QAlertTypeMetaData> types()
|
||||
{
|
||||
return (List.of(new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
|
||||
return (List.of(new QAlertTypeMetaData().withName(COMPLAINT).withLabel("Email complaints").withDefaultSeverity(AlertSeverity.WARNING)
|
||||
.withSubjectTableName(EmailTables.EVENT).withVisibilityPermission("email.view").withSourceName("email"),
|
||||
new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
|
||||
.withSubjectTableName(EmailTables.PROVIDER).withVisibilityPermission("email.admin").withSourceName("email"),
|
||||
new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Email dead letters").withDefaultSeverity(AlertSeverity.WARNING)
|
||||
.withSubjectTableName(EmailTables.MESSAGE).withVisibilityPermission("email.view").withSourceName("email")));
|
||||
@@ -62,7 +65,11 @@ public final class EmailAlerts
|
||||
public static void raise(String type, Integer providerId)
|
||||
{
|
||||
String key = "provider:" + providerId + (DEAD_LETTER.equals(type) ? ":" + EmailRuntime.clock().instant().getEpochSecond() / 3600 : "");
|
||||
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
|
||||
if(COMPLAINT.equals(type))
|
||||
{
|
||||
key = "complaints:" + EmailRuntime.clock().instant().getEpochSecond() / 86400;
|
||||
}
|
||||
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(COMPLAINT.equals(type) ? "More than three email complaints in 24 hours" : UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
|
||||
.withSummary("Review the Email tables for status and retry actions."));
|
||||
}
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ public class EmailDispatcher
|
||||
*******************************************************************************/
|
||||
private static EmailEnvelope envelope(QRecord message)
|
||||
{
|
||||
return (new EmailEnvelope().withMessageId(message.getValueString("id")).withFrom(message.getValueString("fromAddress"))
|
||||
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"))
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health adapters reuse readiness and passive delivery state; never send.
|
||||
*******************************************************************************/
|
||||
public class EmailHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Register without querying data during metadata production.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
if(registry.getChecks().stream().noneMatch(check -> "email.readiness".equals(check.getName())))
|
||||
{
|
||||
registry.withCheck(definition("email.readiness", EmailHealthChecks::readiness));
|
||||
}
|
||||
if(registry.getSuppliers().stream().noneMatch(supplier -> "email.provider".equals(supplier.getNamePrefix())))
|
||||
{
|
||||
registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("email.provider").withComponent("email")
|
||||
.withSupplier(new QCodeReference(EmailHealthChecks.class)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider primary keys are integers, so their names are already lowercase.
|
||||
** Capture only identity; each execution rereads current state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
|
||||
{
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(QRecord provider : providers(EmailStore.equal("enabled", true), Set.of("id")))
|
||||
{
|
||||
Integer id = provider.getValueInteger("id");
|
||||
checks.add(definition("email.provider." + id, input -> provider(id, input)));
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound execution cooperatively around existing reads. Core also enforces
|
||||
** the hard timeout. Exception text may contain PHI or secrets: omit it.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckMetaData definition(String name, HealthCheck check)
|
||||
{
|
||||
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("email")
|
||||
.withCategory(HealthCategory.EMAIL).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(EmailHealthWidget.CHECK_INTERVAL_MINUTES * 60).withTimeoutMs(5000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
{
|
||||
requireTime(input);
|
||||
HealthCheckOutcome outcome = check.run(input);
|
||||
requireTime(input);
|
||||
return (outcome);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Email health check failed: " + e.getClass().getSimpleName()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate all category preflights, exposing enum names instead of reasons.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome readiness(HealthCheckInput input) throws QException
|
||||
{
|
||||
Set<EmailApiReadinessCheck> failed = EnumSet.noneOf(EmailApiReadinessCheck.class);
|
||||
boolean ready = true;
|
||||
for(EmailApiCategory category : EmailApiCategory.values())
|
||||
{
|
||||
requireTime(input);
|
||||
var readiness = EmailApi.readiness(input.getQInstance(), category);
|
||||
ready &= readiness.ready();
|
||||
failed.addAll(readiness.failedChecks());
|
||||
}
|
||||
if(ready)
|
||||
{
|
||||
return (HealthCheckOutcome.up("Email configuration ready").withDetails(new HealthDetails().withValue("failedChecks", "")));
|
||||
}
|
||||
var config = EmailRuntime.config(input.getQInstance());
|
||||
String app = config == null || config.getAppLabel() == null ? "Email" : config.getAppLabel();
|
||||
List<String> fixes = failed.stream().map(check -> fix(check, app)).toList();
|
||||
String message = "Email configuration incomplete: " + String.join(" ", fixes);
|
||||
String details = String.join("\n", fixes);
|
||||
return (HealthCheckOutcome.degraded(message.length() > 500 ? message.substring(0, 500) : message)
|
||||
.withDetails(new HealthDetails().withValue("failedChecks", failed.stream().map(Enum::name).collect(Collectors.joining(",")))
|
||||
.withValue("howToFix", details.length() > 4000 ? details.substring(0, 4000) : details)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain-words statement of one missing prerequisite and where to set it.
|
||||
*******************************************************************************/
|
||||
private static String fix(EmailApiReadinessCheck check, String app)
|
||||
{
|
||||
return (switch(check)
|
||||
{
|
||||
case NOT_INSTALLED -> "The Email qbit is not installed.";
|
||||
case PROVIDER_MISSING -> "No enabled default email provider (" + app + " > Provider: add one and tick Enabled and Is Default).";
|
||||
case PROVIDER_INVALID -> "The default email provider settings are invalid (" + app + " > Provider: open the default provider and correct it).";
|
||||
case PHI_PROVIDER -> "PHI email needs a PHI-enabled provider with open and click tracking off (" + app + " > Provider).";
|
||||
case PUBLIC_BASE_URL -> "Public base URL is not set (the publicBaseUrl setting in the Email qbit config). Needed for marketing unsubscribe links; transactional email is unaffected.";
|
||||
case UNSUBSCRIBE_KEY -> "The QQQ_EMAIL_UNSUBSCRIBE_KEY secret is not set (deployment environment variable). Needed for marketing unsubscribe links; transactional email is unaffected.";
|
||||
case BRAND_POSTAL_ADDRESS -> "Brand postal address is not set (" + app + " > Brand > open the default brand > Postal address). Needed for marketing email footers; transactional email is unaffected.";
|
||||
case BODY_KEY -> "The QQQ_EMAIL_BODY_KEY secret is not set (deployment environment variable). Needed for PHI email.";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The transport SPI has no non-sending connectivity method. Passive state
|
||||
** is the only signal; never read or publish lastFailureMessage.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome provider(Integer id, HealthCheckInput input) throws QException
|
||||
{
|
||||
List<QRecord> rows = providers(EmailStore.equal("id", id).withLimit(1), Set.of("id", "enabled", "healthStatus", "lastSuccessDate"));
|
||||
requireTime(input);
|
||||
if(rows.isEmpty() || !Boolean.TRUE.equals(rows.getFirst().getValueBoolean("enabled")))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Provider disabled or removed"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
String state = row.getValueString("healthStatus");
|
||||
HealthCheckOutcome outcome = "HEALTHY".equals(state) ? HealthCheckOutcome.up("Passive provider state healthy")
|
||||
: "UNHEALTHY".equals(state) ? HealthCheckOutcome.down("Passive provider state unhealthy; see provider record")
|
||||
: HealthCheckOutcome.unknown("Passive provider state unavailable");
|
||||
HealthDetails details = new HealthDetails().withValue("providerId", id).withValue("passive", true);
|
||||
Instant lastSuccess = row.getValueInstant("lastSuccessDate");
|
||||
if(lastSuccess != null)
|
||||
{
|
||||
details.withValue("lastSuccessDate", lastSuccess);
|
||||
}
|
||||
return (outcome.withDetails(details));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select only needed fields under the qbit's established system read scope.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> providers(QQueryFilter filter, Set<String> fields) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(EmailTables.PROVIDER).withFilter(filter)
|
||||
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not start further work after the runner's deadline or cancellation.
|
||||
*******************************************************************************/
|
||||
private static void requireTime(HealthCheckInput input) throws QException
|
||||
{
|
||||
if(Thread.currentThread().isInterrupted() || (input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline())))
|
||||
{
|
||||
throw (new QException("Health check deadline exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.health;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard status line linking to central checks; no provider diagnostics.
|
||||
*******************************************************************************/
|
||||
public class EmailHealthWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String NOT_CHECKED = "Not checked yet";
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// the interval of the email health checks (EmailHealthChecks), in minutes //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
static final int CHECK_INTERVAL_MINUTES = 5;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Human words for a health status; unknown means no check has reported yet.
|
||||
*******************************************************************************/
|
||||
static String wording(String status)
|
||||
{
|
||||
return (switch(status)
|
||||
{
|
||||
case "UP" -> "Healthy";
|
||||
case "DEGRADED" -> "Degraded";
|
||||
case "DOWN" -> "Down";
|
||||
default -> NOT_CHECKED;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the persisted rollup as the viewer, preserving health permissions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String status = HealthStatus.UNKNOWN.name();
|
||||
boolean installed = QContext.getQInstance().getTable(HealthMetaDataProvider.COMPONENT_TABLE) != null;
|
||||
boolean readable = installed && PermissionsHelper.hasTablePermission(input, HealthMetaDataProvider.COMPONENT_TABLE, TablePermissionSubType.READ);
|
||||
if(readable)
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(HealthMetaDataProvider.COMPONENT_TABLE)
|
||||
.withFilter(EmailStore.equal("name", "email").withLimit(1)).withFieldNamesToInclude(Set.of("status"))).getRecords();
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
String stored = rows.getFirst().getValueString("status");
|
||||
for(HealthStatus candidate : HealthStatus.values())
|
||||
{
|
||||
if(candidate.name().equals(stored))
|
||||
{
|
||||
status = candidate.name();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String context = !installed ? "Email - health monitoring not configured" : readable ? "Email - System Health. Checks run every " + CHECK_INTERVAL_MINUTES + " minutes" : "Email - health access required";
|
||||
StatisticsData data = new StatisticsData().withCount(wording(status)).withCountFontSize("1rem").withCountContext(context);
|
||||
if(readable)
|
||||
{
|
||||
data.withCountURL("/app/health_check?filter=" + URLEncoder.encode(JsonUtils.toJson(EmailStore.equal("component", "email")), StandardCharsets.UTF_8));
|
||||
}
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,8 @@ public final class EmailEnums
|
||||
{
|
||||
MARKDOWN,
|
||||
MJML,
|
||||
HTML;
|
||||
HTML,
|
||||
CRM_LEGACY;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
@@ -312,4 +313,88 @@ public final class EmailEnums
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** InboxStatus values.
|
||||
*******************************************************************************/
|
||||
public enum InboxStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
PENDING, PROCESSED, FAILED, IGNORED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable stored identifier.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** EventType values.
|
||||
*******************************************************************************/
|
||||
public enum EventType implements PossibleValueEnum<String>
|
||||
{
|
||||
DELIVERED, DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable stored identifier.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** BounceType values.
|
||||
*******************************************************************************/
|
||||
public enum BounceType implements PossibleValueEnum<String>
|
||||
{
|
||||
HARD, SOFT, UNDETERMINED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable stored identifier.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,23 +21,32 @@
|
||||
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;
|
||||
@@ -197,6 +206,13 @@ public final class EmailTableCustomizers
|
||||
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);
|
||||
@@ -218,9 +234,16 @@ public final class EmailTableCustomizers
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(!SystemWrites.query(EmailTables.MESSAGE, com.kingsrook.qbits.email.support.EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// the same history the RESTRICT foreign keys protect (docs/ddl) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
for(String history : List.of(EmailTables.MESSAGE, EmailTables.ATTEMPT, EmailTables.EVENT, EmailTables.INBOX))
|
||||
{
|
||||
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
|
||||
if(!SystemWrites.query(history, EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
|
||||
{
|
||||
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
List<QRecord> remaining = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(), input.getTransaction()));
|
||||
@@ -230,6 +253,13 @@ public final class EmailTableCustomizers
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -272,6 +302,106 @@ public final class EmailTableCustomizers
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -26,6 +26,7 @@ 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;
|
||||
@@ -53,15 +54,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
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 List<String> ALL = List.of(PROVIDER, MESSAGE, ATTEMPT, TEMPLATE, VERSION, BRAND, SUPPRESSION);
|
||||
public static final String INBOX = "email_webhook_inbox";
|
||||
public static final String EVENT = "email_event";
|
||||
public static final List<String> ALL = List.of(PROVIDER, 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);
|
||||
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT, INBOX, EVENT);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
@@ -95,12 +99,16 @@ public final class EmailTables
|
||||
result.add(QPossibleValueSource.newForEnum("email_suppression_scope", EmailEnums.SuppressionScope.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_suppression_reason", EmailEnums.SuppressionReason.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_category", EmailCategory.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_inbox_status", EmailEnums.InboxStatus.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_event_type", EmailEnums.EventType.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_bounce_type", EmailEnums.BounceType.values()));
|
||||
ALL.forEach(name -> result.add(QPossibleValueSource.newForTable(name)));
|
||||
for(QPossibleValueSource source : result)
|
||||
{
|
||||
if(List.of(PROVIDER, MESSAGE, VERSION).contains(source.getName()))
|
||||
if(List.of(PROVIDER, SENDER, MESSAGE, VERSION).contains(source.getName()))
|
||||
{
|
||||
source.withSearchFields(PROVIDER.equals(source.getName()) ? List.of("label", "name") : MESSAGE.equals(source.getName()) ? List.of("subject") : List.of("versionNumber", "locale"))
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -108,18 +116,21 @@ public final class EmailTables
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The seven phase (a) tables.
|
||||
** 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())
|
||||
@@ -203,7 +214,31 @@ public final class EmailTables
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -219,10 +254,11 @@ public final class EmailTables
|
||||
.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(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.templateId}")))
|
||||
.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))
|
||||
@@ -233,6 +269,7 @@ public final class EmailTables
|
||||
.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))
|
||||
@@ -255,10 +292,12 @@ public final class EmailTables
|
||||
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("replyTo", "subject", "templateId", "templateVersionId", "brandId", "category", "locale", "variablesSnapshot", "htmlBody", "textBody", "phiApproved", "unsubscribeUrl", "bodySha256", "idempotencyKey", "providerId", "providerMessageId", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastError", "finishedDate", "sentDate", "deliveredDate", "bouncedDate", "complainedDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderQbit", "senderProcess", "suppressedAddresses", "sendAfter"));
|
||||
table.withSection(SectionFactory.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);
|
||||
}
|
||||
|
||||
@@ -269,7 +308,7 @@ public final class EmailTables
|
||||
private static QTableMetaData messageattempt(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ATTEMPT, "Message Attempt")
|
||||
.withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE).withIsRequired(true))
|
||||
.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))
|
||||
@@ -298,14 +337,15 @@ public final class EmailTables
|
||||
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
|
||||
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_template_kind").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
|
||||
.withField(field("activeVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION).withIsEditable(false).withPossibleValueSourceFilter(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.id}")))
|
||||
.withField(field("approvedVersionId", QFieldType.INTEGER).withIsEditable(false).withPossibleValueSourceName(VERSION).withPossibleValueSourceFilter(com.kingsrook.qbits.email.support.EmailStore.equal("templateId", "${input.id}")))
|
||||
.withField(field("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("ownerQbit", QFieldType.STRING).withMaxLength(500).withIsEditable(false)
|
||||
.withHelpContent(new com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent("Set by the qbit that registered the template. Messages sent from it name that qbit as their sender unless the sender says otherwise.")))
|
||||
.withField(field("description", QFieldType.TEXT));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
@@ -390,7 +430,7 @@ public final class EmailTables
|
||||
.withField(field("scope", QFieldType.STRING).withPossibleValueSourceName("email_suppression_scope").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("reason", QFieldType.STRING).withPossibleValueSourceName("email_suppression_reason").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("sourceMessageId", QFieldType.INTEGER))
|
||||
.withField(field("sourceEventId", QFieldType.INTEGER))
|
||||
.withField(field("sourceEventId", QFieldType.INTEGER).withPossibleValueSourceName(EVENT).withPossibleValueSourceFilter(EmailStore.equal("messageId", "${input.sourceMessageId}")))
|
||||
.withField(field("note", QFieldType.TEXT))
|
||||
.withField(field("createdDate", QFieldType.DATE_TIME))
|
||||
.withField(field("expiresDate", QFieldType.DATE_TIME));
|
||||
@@ -401,12 +441,59 @@ public final class EmailTables
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System-written webhook inbox records.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData inbox(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, INBOX, "Webhook Inbox")
|
||||
.withField(field("providerId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(PROVIDER))
|
||||
.withField(field("kind", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("externalEventId", QFieldType.STRING).withMaxLength(500).withIsRequired(true))
|
||||
.withField(field("nonce", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("status", QFieldType.STRING).withMaxLength(500).withIsRequired(true).withPossibleValueSourceName("email_inbox_status"))
|
||||
.withField(field("attempts", QFieldType.INTEGER))
|
||||
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
|
||||
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("payloadJson", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("payloadSha256", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("receivedDate", QFieldType.DATE_TIME))
|
||||
.withField(field("processedDate", QFieldType.DATE_TIME));
|
||||
table.withUniqueKey(new UniqueKey("providerId", "externalEventId"));
|
||||
table.withUniqueKey(new UniqueKey("providerId", "nonce"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "providerId", "kind", "externalEventId", "nonce", "status", "attempts", "nextAttemptDate", "lastError", "payloadJson", "payloadSha256", "receivedDate", "processedDate"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** System-written event records.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData event(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, EVENT, "Event")
|
||||
.withField(field("messageId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(MESSAGE).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")));
|
||||
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")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -426,8 +513,8 @@ public final class EmailTables
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
for(QJoinMetaData join : joins())
|
||||
{
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getLeftTable() + "_children")
|
||||
.withLabel(join.getRightTable().equals(ATTEMPT) ? "Attempts" : join.getRightTable().equals(VERSION) ? "Versions" : "Messages")
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(List.of(INBOX, EVENT, 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);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.outbox;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal; use EmailApi.
|
||||
** Durable handoff outcomes. Operational failures propagate to the caller.
|
||||
*******************************************************************************/
|
||||
@Deprecated(forRemoval = false)
|
||||
public enum EmailEffectOutcome
|
||||
{
|
||||
QUEUED_OR_DUPLICATE,
|
||||
REFUSED
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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, List<String> reasons)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve the original constructor for callers that only need the reference.
|
||||
*******************************************************************************/
|
||||
public Result(EmailEffectOutcome outcome, EmailMessageRef message)
|
||||
{
|
||||
this(outcome, message, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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, List.of("An email request without a caller transaction and a valid, matching effect key are required")));
|
||||
}
|
||||
request.withIdempotencyKey(effectIdempotencyKey);
|
||||
EmailSendResult result = EmailService.send(request);
|
||||
EmailMessageRef message = result.messageId() == null ? null : find("id", result.messageId()).orElseThrow(() -> new QException("Queued email reference is unavailable"));
|
||||
boolean refused = result.status() == EmailSendResult.Status.REFUSED || result.status() == EmailSendResult.Status.SUPPRESSED
|
||||
|| message != null && "SUPPRESSED".equals(message.status());
|
||||
List<String> reasons = message != null && "SUPPRESSED".equals(message.status()) ? List.of("All recipients are suppressed") : result.reasons();
|
||||
return (new Result(refused ? EmailEffectOutcome.REFUSED : EmailEffectOutcome.QUEUED_OR_DUPLICATE, message, reasons));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.outbox;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.jobs.JobSessions;
|
||||
import com.kingsrook.qqq.backend.core.jobs.effects.EffectRequest;
|
||||
import com.kingsrook.qqq.backend.core.jobs.effects.EffectResult;
|
||||
import com.kingsrook.qqq.backend.core.jobs.effects.EffectTarget;
|
||||
import com.kingsrook.qqq.backend.core.jobs.effects.ReconcileResult;
|
||||
import com.kingsrook.qqq.backend.core.jobs.store.DurableJobStore;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable jobs target for IDEMPOTENT_EXTERNAL email effects. SENT acknowledges
|
||||
** the committed email outbox, whose key deduplication makes retries safe. The
|
||||
** email dispatcher owns provider delivery and preserves the stored Message-ID.
|
||||
** Payload keys and types are documented in docs/DURABLE-JOBS.md.
|
||||
*******************************************************************************/
|
||||
public class EmailJobEffectTarget implements EffectTarget
|
||||
{
|
||||
public static final String TARGET_NAME = "email";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register after enabling durable jobs; leave instances without jobs alone.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
DurableJobsMetaData jobs = DurableJobsMetaData.of(instance);
|
||||
if(jobs != null)
|
||||
{
|
||||
jobs.withEffectTarget(TARGET_NAME, new QCodeReference(EmailJobEffectTarget.class));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue under the job owner's current permissions and captured scope.
|
||||
** Other exceptions are uncertain; core may safely resend using the same key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EffectResult deliver(EffectRequest r)
|
||||
{
|
||||
try
|
||||
{
|
||||
EmailRequest request = decode(r.payload()).withSender("jobs", r.effectName());
|
||||
EmailEffects.Result result;
|
||||
if(r.jobId() == null)
|
||||
{
|
||||
result = EmailEffects.send(request, r.idempotencyKey());
|
||||
}
|
||||
else
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
QSession owner;
|
||||
try
|
||||
{
|
||||
QRecord job = new DurableJobStore(DurableJobsMetaData.of(instance)).getJob(r.jobId());
|
||||
if(job == null)
|
||||
{
|
||||
return (EffectResult.failed("The job's owner can no longer send it: job not found"));
|
||||
}
|
||||
owner = JobSessions.forJob(instance, job);
|
||||
}
|
||||
catch(QPermissionDeniedException | QAuthenticationException e)
|
||||
{
|
||||
return (EffectResult.failed("The job's owner can no longer send it: owner session unavailable"));
|
||||
}
|
||||
AtomicReference<EmailEffects.Result> handoff = new AtomicReference<>();
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, owner),
|
||||
() -> handoff.set(EmailEffects.send(request, r.idempotencyKey())));
|
||||
result = handoff.get();
|
||||
}
|
||||
if(result.outcome() == EmailEffectOutcome.REFUSED)
|
||||
{
|
||||
return (EffectResult.failed(String.join("; ", result.reasons())));
|
||||
}
|
||||
return (EffectResult.sent(String.valueOf(result.message().id())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Exception details can contain payloads or database values.
|
||||
return (EffectResult.unknown("Email handoff could not be confirmed (" + e.getClass().getSimpleName() + ")"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconcile handoff by existence, including suppressed and terminal audit rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ReconcileResult reconcile(EffectRequest r) throws QException
|
||||
{
|
||||
return (EmailEffects.findByIdempotencyKey(r.idempotencyKey())
|
||||
.map(message -> ReconcileResult.found(String.valueOf(message.id())))
|
||||
.orElseGet(ReconcileResult::notFound));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decode only supported values; identity, sender and transaction cannot be
|
||||
** overridden by payload keys. Variables retain normal template/PHI validation.
|
||||
*******************************************************************************/
|
||||
static EmailRequest decode(Map<String, Serializable> payload)
|
||||
{
|
||||
Map<String, Serializable> values = payload == null ? Map.of() : payload;
|
||||
EmailRequest request = new EmailRequest()
|
||||
.withTo(addresses(values.get("to")))
|
||||
.withCc(addresses(values.get("cc")))
|
||||
.withBcc(addresses(values.get("bcc")))
|
||||
.withFrom((String) values.get("from"))
|
||||
.withFromAddressId((Integer) values.get("fromAddressId"))
|
||||
.withReplyTo((String) values.get("replyTo"))
|
||||
.withSubject((String) values.get("subject"))
|
||||
.withTemplateName((String) values.get("templateName"))
|
||||
.withMarkdownBody((String) values.get("markdownBody"))
|
||||
.withHtmlBody((String) values.get("htmlBody"))
|
||||
.withTextBody((String) values.get("textBody"))
|
||||
.withProviderName((String) values.get("providerName"))
|
||||
.withBrandName((String) values.get("brandName"))
|
||||
.withLinkedRecord((String) values.get("linkedTableName"), values.get("linkedRecordId"));
|
||||
if(values.get("locale") != null)
|
||||
{
|
||||
request.withLocale((String) values.get("locale"));
|
||||
}
|
||||
if(values.get("category") != null)
|
||||
{
|
||||
request.withCategory(EmailCategory.valueOf((String) values.get("category")));
|
||||
}
|
||||
if(values.get("sendAfter") != null)
|
||||
{
|
||||
request.withSendAfter(Instant.parse((String) values.get("sendAfter")));
|
||||
}
|
||||
if(values.get("variables") instanceof Map<?, ?> variables)
|
||||
{
|
||||
variables.forEach((key, value) -> request.withVariable((String) key, (Serializable) value));
|
||||
}
|
||||
else if(values.get("variables") != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("variables must be a map"));
|
||||
}
|
||||
return (request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept one mailbox or a list of mailboxes, without comma splitting.
|
||||
*******************************************************************************/
|
||||
private static List<String> addresses(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
if(value instanceof String address)
|
||||
{
|
||||
return (List.of(address));
|
||||
}
|
||||
return (((List<?>) value).stream().map(String.class::cast).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.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)
|
||||
{
|
||||
}
|
||||
@@ -39,6 +39,7 @@ public class EmailRequest
|
||||
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;
|
||||
@@ -213,6 +214,26 @@ public class EmailRequest
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -30,12 +30,14 @@ 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;
|
||||
@@ -163,16 +165,29 @@ public final class EmailService
|
||||
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 ? provider.getValueString("fromAddress") : request.getFrom();
|
||||
String sender = request.getFrom() != null ? request.getFrom() : senderRow != null ? senderRow.getValueString("address") : provider.getValueString("fromAddress");
|
||||
from = single(sender);
|
||||
if(request.getFrom() == null && provider.getValueString("fromName") != null)
|
||||
if(request.getFrom() == null)
|
||||
{
|
||||
from = new InternetAddress(new InternetAddress(from).getAddress(), provider.getValueString("fromName"), "UTF-8").toString();
|
||||
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);
|
||||
@@ -187,6 +202,18 @@ public final class EmailService
|
||||
{
|
||||
return (refused("One or more email addresses are invalid"));
|
||||
}
|
||||
try
|
||||
{
|
||||
String unsubscribe = EmailUnsubscribeLinks.create("pending", String.join(",", to), category.name());
|
||||
if(unsubscribe != null || retained != null && retained.getValueString("unsubscribeUrl") != null)
|
||||
{
|
||||
EmailUnsubscribeLinks.recipient(String.join(",", to), String.join(",", cc), String.join(",", bcc));
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (refused(e.getMessage()));
|
||||
}
|
||||
if(to.isEmpty() && cc.isEmpty() && bcc.isEmpty())
|
||||
{
|
||||
return (refused("At least one recipient is required"));
|
||||
@@ -214,9 +241,14 @@ public final class EmailService
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -226,9 +258,11 @@ public final class EmailService
|
||||
QRecord row = new QRecord().withValue("providerId", provider.getValue("id"))
|
||||
.withValue("status", allSuppressed ? "SUPPRESSED" : "QUEUED").withValue("finishedDate", allSuppressed ? now : null).withValue("attemptCount", 0)
|
||||
.withValue("toAddresses", String.join(",", to)).withValue("ccAddresses", String.join(",", cc)).withValue("bccAddresses", String.join(",", bcc))
|
||||
.withValue("fromAddress", from).withValue("replyTo", replyTo).withValue("subject", request.getSubject())
|
||||
.withValue("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())
|
||||
@@ -236,14 +270,14 @@ public final class EmailService
|
||||
if(templateContent != null)
|
||||
{
|
||||
row.getValues().putAll(templateContent.getValues());
|
||||
}
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("pending", String.join(",", to), category.name());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (refused(e.getMessage()));
|
||||
if(row.getValue("senderQbit") == null && row.getValue("templateId") != null)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// the qbit that registered the template is the sender when none was named //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> owner = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValue("templateId")), queryTransaction);
|
||||
row.setValue("senderQbit", owner.isEmpty() ? null : owner.getFirst().getValueString("ownerQbit"));
|
||||
}
|
||||
}
|
||||
for(var field : config.getSecurityFields())
|
||||
{
|
||||
@@ -293,7 +327,7 @@ public final class EmailService
|
||||
return (refused("Provider is unavailable"));
|
||||
}
|
||||
EmailRequest request = new EmailRequest().withSubject(row.getValueString("subject")).withHtmlBody(row.getValueString("htmlBody"))
|
||||
.withTextBody(row.getValueString("textBody")).withFrom(row.getValueString("fromAddress")).withReplyTo(row.getValueString("replyTo"))
|
||||
.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())
|
||||
@@ -367,26 +401,18 @@ public final class EmailService
|
||||
{
|
||||
return (new EmailSendResult(existing.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
|
||||
}
|
||||
QRecord inserted;
|
||||
try
|
||||
{
|
||||
inserted = SystemWrites.insert(EmailTables.MESSAGE, List.of(row), transaction).getRecords().get(0);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (refused("Could not queue the email message"));
|
||||
}
|
||||
QRecord inserted = SystemWrites.insert(EmailTables.MESSAGE, List.of(row), transaction).getRecords().get(0);
|
||||
if(inserted.getErrors() != null && !inserted.getErrors().isEmpty())
|
||||
{
|
||||
return (refused("Could not queue the email message"));
|
||||
throw (new QException("Could not queue the email message"));
|
||||
}
|
||||
try
|
||||
{
|
||||
String unsubscribe = row.getValueString("unsubscribeUrl");
|
||||
if(unsubscribe == null && (templateRenderer != null || !Boolean.TRUE.equals(row.getValueBoolean("phiApproved"))))
|
||||
if(unsubscribe == null && !"SUPPRESSED".equals(row.getValueString("status"))
|
||||
&& (templateRenderer != null || !Boolean.TRUE.equals(row.getValueBoolean("phiApproved"))))
|
||||
{
|
||||
unsubscribe = com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create(inserted.getValueString("id"),
|
||||
row.getValueString("toAddresses") + "," + row.getValueString("ccAddresses") + "," + row.getValueString("bccAddresses"), row.getValueString("category"));
|
||||
unsubscribe = EmailUnsubscribeLinks.create(inserted.getValueString("id"), row.getValueString("toAddresses"), row.getValueString("category"));
|
||||
}
|
||||
QRecord content;
|
||||
if(templateRenderer != null)
|
||||
@@ -468,6 +494,57 @@ public final class EmailService
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -521,6 +598,42 @@ public final class EmailService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.kingsrook.qbits.email.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
@@ -30,6 +31,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -58,7 +60,7 @@ public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
throw (new QPermissionDeniedException("Email messages and attempts are written only by the email qbit. Use Send Now, Resend or Cancel."));
|
||||
}
|
||||
if(!has(needed))
|
||||
if(!has(needed) && !hasTemplateGrant(actionInput, metaData))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
@@ -66,6 +68,36 @@ public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Additional host permissions are restricted to template data and lifecycle.
|
||||
*******************************************************************************/
|
||||
private static boolean hasTemplateGrant(AbstractActionInput input, MetaDataWithPermissionRules metaData)
|
||||
{
|
||||
if(metaData instanceof QTableMetaData table
|
||||
&& (EmailTables.TEMPLATE.equals(table.getName()) || EmailTables.VERSION.equals(table.getName())))
|
||||
{
|
||||
return (hasTemplatePermission(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput));
|
||||
}
|
||||
if(metaData instanceof QAppMetaData app && EmailRuntime.config() != null && EmailRuntime.config().getAppName().equals(app.getName()))
|
||||
{
|
||||
return (hasTemplatePermission(false));
|
||||
}
|
||||
return (metaData instanceof QProcessMetaData process
|
||||
&& (EmailProcesses.NEW_VERSION.equals(process.getName()) || EmailProcesses.ACTIVATE_VERSION.equals(process.getName()))
|
||||
&& hasTemplatePermission(true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check only the configured read/write grant, without implying email.manage.
|
||||
*******************************************************************************/
|
||||
public static boolean hasTemplatePermission(boolean write)
|
||||
{
|
||||
var config = EmailRuntime.config();
|
||||
String base = config == null ? null : config.getTemplatePermissionBaseName();
|
||||
QSession session = QContext.getQSession();
|
||||
return (base != null && !base.isBlank() && session != null && session.hasPermission(base + (write ? ".write" : ".read")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission an action needs (null: nobody may do it directly).
|
||||
*******************************************************************************/
|
||||
@@ -74,7 +106,7 @@ public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput;
|
||||
if(metaData instanceof QTableMetaData table)
|
||||
{
|
||||
if(EmailTables.PROVIDER.equals(table.getName()))
|
||||
if(EmailTables.PROVIDER.equals(table.getName()) || EmailTables.SENDER.equals(table.getName()))
|
||||
{
|
||||
return (EmailPermissions.ADMIN);
|
||||
}
|
||||
|
||||
@@ -86,8 +86,13 @@ public final class EmailProcesses
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -102,7 +107,7 @@ public final class EmailProcesses
|
||||
{
|
||||
return (switch(name)
|
||||
{
|
||||
case RESEND, CANCEL, SEND_NOW, NEW_VERSION, ACTIVATE_VERSION -> EmailPermissions.MANAGE;
|
||||
case REPROCESS, RESEND, CANCEL, SEND_NOW, NEW_VERSION, ACTIVATE_VERSION -> EmailPermissions.MANAGE;
|
||||
case SEND_TEMPLATED, SEND_AD_HOC -> EmailPermissions.SEND;
|
||||
default -> EmailPermissions.ADMIN;
|
||||
});
|
||||
@@ -114,9 +119,12 @@ public final class EmailProcesses
|
||||
public static List<QProcessMetaData> processes(EmailQBitConfig config)
|
||||
{
|
||||
QProcessMetaData test = action(SEND_TEST, "Send Test Email", EmailTables.PROVIDER);
|
||||
test.withStep(0, new QFrontendStepMetaData().withName("input").withLabel("Recipient")
|
||||
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("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",
|
||||
@@ -126,6 +134,12 @@ public final class EmailProcesses
|
||||
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));
|
||||
@@ -136,6 +150,7 @@ public final class EmailProcesses
|
||||
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)));
|
||||
}
|
||||
@@ -175,6 +190,10 @@ public final class EmailProcesses
|
||||
*******************************************************************************/
|
||||
private static boolean eligible(String process, QRecord row)
|
||||
{
|
||||
if(REPROCESS.equals(process))
|
||||
{
|
||||
return ("FAILED".equals(row.getValueString("status")));
|
||||
}
|
||||
if(NEW_VERSION.equals(process))
|
||||
{
|
||||
return (true);
|
||||
@@ -266,7 +285,7 @@ public final class EmailProcesses
|
||||
{
|
||||
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
|
||||
: SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
|
||||
: REPROCESS.equals(process) ? EmailTables.INBOX : SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
|
||||
String result = "Skipped";
|
||||
if(current != null && eligible(process, current))
|
||||
{
|
||||
@@ -283,10 +302,11 @@ public final class EmailProcesses
|
||||
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"));
|
||||
case SEND_TEST -> sendTest(current, input.getValueString("toAddress"), input.getValueInteger("fromAddressId"));
|
||||
default -> "Skipped";
|
||||
};
|
||||
}
|
||||
@@ -340,17 +360,45 @@ public final class EmailProcesses
|
||||
/*******************************************************************************
|
||||
** Queue and dispatch a fixed provider test message.
|
||||
*******************************************************************************/
|
||||
private static String sendTest(QRecord provider, String to) throws QException
|
||||
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)
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -362,10 +410,20 @@ public final class EmailProcesses
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
EmailRequest request = new EmailRequest().withTemplateName(input.getValueString("templateName"))
|
||||
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(input.getValueString("providerName"))
|
||||
.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)
|
||||
@@ -431,6 +489,7 @@ public final class EmailProcesses
|
||||
{
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case PROCESS_WEBHOOKS -> new com.kingsrook.qbits.email.tracking.EmailInboxProcessor().runOnce();
|
||||
case RETENTION -> retain();
|
||||
case MAINTAIN -> new EmailDispatcher().maintain();
|
||||
default -> new EmailDispatcher().runOnce();
|
||||
@@ -440,6 +499,8 @@ public final class EmailProcesses
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear retained bodies and optionally delete final messages and their attempts.
|
||||
** NOTIFICATION and MARKETING messages are never deleted (their unsubscribe links must
|
||||
** keep working); at message retention only their bodies and variable snapshot are cleared.
|
||||
*******************************************************************************/
|
||||
public static void retain() throws QException
|
||||
{
|
||||
@@ -464,18 +525,35 @@ public final class EmailProcesses
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays()))))
|
||||
boolean expired = config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays())));
|
||||
boolean optOut = List.of("NOTIFICATION", "MARKETING").contains(row.getValueString("category"));
|
||||
if(expired && !optOut)
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
if(row.getValueInteger("providerId") != null)
|
||||
{
|
||||
com.kingsrook.qbits.email.tracking.EmailTrackingStore.lock(row.getValueInteger("providerId"), transaction);
|
||||
}
|
||||
SystemWrites.delete(EmailTables.EVENT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
|
||||
SystemWrites.delete(EmailTables.ATTEMPT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
|
||||
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", row.getValueInteger("id")), transaction);
|
||||
transaction.commit();
|
||||
}
|
||||
}
|
||||
else if(finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))) && (row.getValue("htmlBody") != null || row.getValue("textBody") != null))
|
||||
else if(expired || finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))))
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null));
|
||||
// Notification and marketing rows are never deleted, so their unsubscribe links keep working; only content goes.
|
||||
boolean clearSnapshot = expired && row.getValue("variablesSnapshot") != null;
|
||||
if(row.getValue("htmlBody") != null || row.getValue("textBody") != null || clearSnapshot)
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null);
|
||||
if(clearSnapshot)
|
||||
{
|
||||
update.withValue("variablesSnapshot", null);
|
||||
}
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,15 @@ 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 from DESIGN 8.2. The receiving route is phase (c).
|
||||
** Queue-time link signing and verification from DESIGN 8.2.
|
||||
*******************************************************************************/
|
||||
public final class EmailUnsubscribeLinks
|
||||
{
|
||||
@@ -45,39 +47,104 @@ public final class EmailUnsubscribeLinks
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the message, recipient-envelope hash and category without exposing PII.
|
||||
** 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 recipients, String category)
|
||||
public static String create(String messageId, String recipient, String category)
|
||||
{
|
||||
if(!"NOTIFICATION".equals(category) && !"MARKETING".equals(category))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String key;
|
||||
try
|
||||
{
|
||||
String key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
String base = EmailRuntime.config().getPublicBaseUrl();
|
||||
if(key == null || key.isBlank() || base == null || base.isBlank())
|
||||
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))
|
||||
{
|
||||
if("MARKETING".equals(category))
|
||||
{
|
||||
throw (new IllegalArgumentException());
|
||||
}
|
||||
return (null);
|
||||
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(recipients.getBytes(StandardCharsets.UTF_8)));
|
||||
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("/+$", "") + "/qqq/hooks/email/unsubscribe/" + signed + "." + signature);
|
||||
return (base.replaceAll("/+$", "") + EmailRuntime.config().getWebhookBasePath() + "/unsubscribe/" + signed + "." + signature);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exactly one To mailbox, canonicalized for signing and category suppression.
|
||||
*******************************************************************************/
|
||||
public static String recipient(String to, String cc, String bcc)
|
||||
{
|
||||
try
|
||||
{
|
||||
InternetAddress[] addresses = InternetAddress.parse(to == null ? "" : to, true);
|
||||
if(addresses.length == 1 && !addresses[0].isGroup() && addresses[0].getAddress().contains("@")
|
||||
&& (cc == null || cc.isBlank()) && (bcc == null || bcc.isBlank()))
|
||||
{
|
||||
addresses[0].validate();
|
||||
return (addresses[0].getAddress().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
catch(jakarta.mail.internet.AddressException e)
|
||||
{
|
||||
// Malformed stored envelopes cannot authorize an opt-out.
|
||||
}
|
||||
throw (new IllegalArgumentException("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify the signed format before any lookup or side effect.
|
||||
** Invalid and non-opt-out categories are indistinguishable to callers.
|
||||
*******************************************************************************/
|
||||
public static org.json.JSONObject verify(String token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(token == null || token.length() > 4096)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String[] parts = token.split("\\.", -1);
|
||||
String key = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
if(parts.length != 3 || !"v1".equals(parts[0]) || key == null || key.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
if(!MessageDigest.isEqual(mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)), Base64.getUrlDecoder().decode(parts[2])))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
var payload = new org.json.JSONObject(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
|
||||
String category = payload.getString("category");
|
||||
return (("MARKETING".equals(category) || "NOTIFICATION".equals(category)) ? payload : null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.support;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qbits.email.api.EmailApiResult;
|
||||
import com.kingsrook.qbits.email.api.EmailApiStatus;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal adapter between the stable contract and the implementation.
|
||||
*******************************************************************************/
|
||||
public final class EmailApiMapper
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailApiMapper()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map every published request field without bypassing send validation.
|
||||
*******************************************************************************/
|
||||
public static EmailRequest request(EmailApiRequest request)
|
||||
{
|
||||
if(request == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
EmailRequest mapped = new EmailRequest()
|
||||
.withTo(request.getTo())
|
||||
.withCc(request.getCc())
|
||||
.withBcc(request.getBcc())
|
||||
.withFrom(request.getFrom())
|
||||
.withFromAddressId(request.getFromAddressId())
|
||||
.withReplyTo(request.getReplyTo())
|
||||
.withSubject(request.getSubject())
|
||||
.withMarkdownBody(request.getMarkdownBody())
|
||||
.withHtmlBody(request.getHtmlBody())
|
||||
.withTextBody(request.getTextBody())
|
||||
.withProviderName(request.getProviderName())
|
||||
.withTemplateName(request.getTemplateName())
|
||||
.withBrandName(request.getBrandName())
|
||||
.withLocale(request.getLocale())
|
||||
.withIdempotencyKey(request.getIdempotencyKey())
|
||||
.withTransaction(request.getTransaction(), request.getTransactionBackendName())
|
||||
.withSendAfter(request.getSendAfter())
|
||||
.withLinkedTableName(request.getLinkedTableName())
|
||||
.withLinkedRecordId(request.getLinkedRecordId())
|
||||
.withSenderQbit(request.getSenderQbit())
|
||||
.withSenderProcess(request.getSenderProcess())
|
||||
.withVariables(request.getVariables())
|
||||
.withCategory(request.getCategory() == null ? null : EmailCategory.valueOf(request.getCategory().name()));
|
||||
request.getRecordVariables().forEach((name, reference) -> mapped.withRecordVariable(name, reference.table(), reference.id()));
|
||||
return (mapped);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map the complete create-once template contract.
|
||||
*******************************************************************************/
|
||||
public static EmailTemplateDefinition template(EmailApiTemplate template)
|
||||
{
|
||||
EmailTemplateDefinition mapped = new EmailTemplateDefinition()
|
||||
.withName(template.getName())
|
||||
.withLabel(template.getLabel())
|
||||
.withKind(template.getKind())
|
||||
.withSubject(template.getSubject())
|
||||
.withBody(template.getBody())
|
||||
.withTextBody(template.getTextBody())
|
||||
.withVariablesSchema(template.getVariablesSchema())
|
||||
.withSampleVariables(template.getSampleVariables())
|
||||
.withOwnerQbit(template.getOwnerQbit())
|
||||
.withEditPermissionName(template.getEditPermissionName())
|
||||
.withCategory(template.getCategory() == null ? null : EmailCategory.valueOf(template.getCategory().name()));
|
||||
template.getVariables().forEach(variable -> mapped.withVariable(variable.name(), variable.label(), variable.type(), variable.required(), variable.classification()));
|
||||
return (mapped);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map enqueue outcomes by their stable names.
|
||||
*******************************************************************************/
|
||||
public static EmailApiResult result(EmailSendResult result)
|
||||
{
|
||||
return (new EmailApiResult(result.messageId(), EmailApiStatus.valueOf(result.status().name()), result.reasons()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.template;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility for migrated CRM content. Customer values are never programs.
|
||||
*******************************************************************************/
|
||||
public final class CrmLegacyTemplateRenderer
|
||||
{
|
||||
private static final Pattern TOKENS = Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmLegacyTemplateRenderer()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the original subject/body override and text-before-HTML behavior.
|
||||
*******************************************************************************/
|
||||
public static QRecord render(QRecord template, QRecord version, Map<String, Serializable> values, String subjectOverride, String bodyOverride)
|
||||
{
|
||||
if(!"crm".equals(template.getValueString("ownerQbit")) || Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM content requires CRM ownership and cannot be PHI approved"));
|
||||
}
|
||||
String subject = subjectOverride == null || subjectOverride.isBlank() ? version.getValueString("subject") : subjectOverride;
|
||||
String body = bodyOverride == null || bodyOverride.isBlank() ? version.getValueString("textSource") : bodyOverride;
|
||||
if(body == null)
|
||||
{
|
||||
body = version.getValueString("bodySource");
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("Email subject and body are required"));
|
||||
}
|
||||
subject = replace(subject, values);
|
||||
body = replace(body, values);
|
||||
if(subject.contains("\r") || subject.contains("\n") || (long) subject.getBytes(StandardCharsets.UTF_8).length + body.getBytes(StandardCharsets.UTF_8).length > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or oversized template output"));
|
||||
}
|
||||
return (new QRecord().withValue("subject", subject).withValue("markdownBody", body)
|
||||
.withValue("templateId", template.getValue("id")).withValue("templateVersionId", version.getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace only tokens in the original source, retaining unknown literals.
|
||||
*******************************************************************************/
|
||||
private static String replace(String source, Map<String, Serializable> values)
|
||||
{
|
||||
Matcher matcher = TOKENS.matcher(source);
|
||||
StringBuilder result = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
Serializable value = values.get(matcher.group(1));
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString()));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
return (result.toString());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public final class EmailBuiltIns
|
||||
{
|
||||
public static final String OWNER = "qbit-email";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
@@ -55,7 +57,7 @@ public final class EmailBuiltIns
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
|
||||
{
|
||||
QRecord template = SystemWrites.insert(EmailTables.TEMPLATE, List.of(new QRecord().withValue("name", "email_secure_message")
|
||||
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false)), tx).getRecords().getFirst();
|
||||
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false).withValue("ownerQbit", OWNER)), tx).getRecords().getFirst();
|
||||
if(template.getValue("id") == null || template.getErrors() != null && !template.getErrors().isEmpty())
|
||||
{
|
||||
tx.rollback();
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.template;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A template another qbit ships with its own code: its name, content and
|
||||
** variable contract. Hand it to {@link EmailTemplates#ensure} at startup or on
|
||||
** first use; it is created once as an ACTIVE version 1 and never overwritten.
|
||||
*******************************************************************************/
|
||||
public final class EmailTemplateDefinition
|
||||
{
|
||||
private String name;
|
||||
private String label;
|
||||
private EmailCategory category;
|
||||
private String kind = "MARKDOWN";
|
||||
private String subject;
|
||||
private String body;
|
||||
private String textBody;
|
||||
private String variablesSchema;
|
||||
private final List<Map<String, Object>> variables = new ArrayList<>();
|
||||
private String sampleVariables;
|
||||
private String ownerQbit;
|
||||
private String editPermissionName;
|
||||
|
||||
/*******************************************************************************
|
||||
** Unique snake_case template name, as used by EmailRequest.withTemplateName.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Human label shown in the template table.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Template category; it decides opt-out and PHI rules.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withCategory(EmailCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** MARKDOWN (the default), MJML or HTML.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Subject source, which may use variables.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withSubject(String subject)
|
||||
{
|
||||
this.subject = subject;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Body source in the template's kind.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional plain-text source; omitted, it is derived from the body.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withTextBody(String textBody)
|
||||
{
|
||||
this.textBody = textBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The variables schema as the JSON list the template table stores. Use this or
|
||||
** withVariable, not both.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withVariablesSchema(String variablesSchema)
|
||||
{
|
||||
this.variablesSchema = variablesSchema;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare one variable (type: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL,
|
||||
** LIST or RECORD; classification: PUBLIC, CONFIDENTIAL, PII, PHI or null).
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withVariable(String name, String label, String type, boolean required, String classification)
|
||||
{
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("name", name);
|
||||
entry.put("label", label);
|
||||
entry.put("type", type);
|
||||
entry.put("required", required);
|
||||
entry.put("classification", classification);
|
||||
variables.add(entry);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Synthetic sample values as a JSON object, used to validate and preview.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withSampleVariables(String sampleVariables)
|
||||
{
|
||||
this.sampleVariables = sampleVariables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit that owns this template.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withOwnerQbit(String ownerQbit)
|
||||
{
|
||||
this.ownerQbit = ownerQbit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional permission, besides the email permission, needed to edit the template.
|
||||
*******************************************************************************/
|
||||
public EmailTemplateDefinition withEditPermissionName(String editPermissionName)
|
||||
{
|
||||
this.editPermissionName = editPermissionName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The template name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The template label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The template category.
|
||||
*******************************************************************************/
|
||||
public EmailCategory getCategory()
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The template kind.
|
||||
*******************************************************************************/
|
||||
public String getKind()
|
||||
{
|
||||
return (kind);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The subject source.
|
||||
*******************************************************************************/
|
||||
public String getSubject()
|
||||
{
|
||||
return (subject);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The body source.
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (body);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional plain-text source.
|
||||
*******************************************************************************/
|
||||
public String getTextBody()
|
||||
{
|
||||
return (textBody);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The schema JSON: the explicit text, or the typed variables as a JSON list.
|
||||
*******************************************************************************/
|
||||
public String getVariablesSchema()
|
||||
{
|
||||
if(variablesSchema != null && !variablesSchema.isBlank())
|
||||
{
|
||||
return (variablesSchema);
|
||||
}
|
||||
return (VariableSchema.json(variables));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The sample values JSON, defaulting to an empty object.
|
||||
*******************************************************************************/
|
||||
public String getSampleVariables()
|
||||
{
|
||||
return (sampleVariables == null || sampleVariables.isBlank() ? "{}" : sampleVariables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The owning qbit.
|
||||
*******************************************************************************/
|
||||
public String getOwnerQbit()
|
||||
{
|
||||
return (ownerQbit);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional edit permission.
|
||||
*******************************************************************************/
|
||||
public String getEditPermissionName()
|
||||
{
|
||||
return (editPermissionName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain-language reasons the definition is incomplete or malformed, before any
|
||||
** rendering check. Empty when it passes.
|
||||
*******************************************************************************/
|
||||
public List<String> problems()
|
||||
{
|
||||
List<String> reasons = new ArrayList<>();
|
||||
if(name == null || !name.matches("[a-z][a-z0-9_]{0,99}"))
|
||||
{
|
||||
reasons.add("Name must be snake_case: lowercase letters, digits and underscores, starting with a letter");
|
||||
}
|
||||
if(label == null || label.isBlank())
|
||||
{
|
||||
reasons.add("Label is required");
|
||||
}
|
||||
if(category == null)
|
||||
{
|
||||
reasons.add("Category is required");
|
||||
}
|
||||
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML", "CRM_LEGACY").contains(kind))
|
||||
{
|
||||
reasons.add("Kind must be MARKDOWN, MJML, HTML or CRM_LEGACY");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class EmailTemplateRenderer
|
||||
{
|
||||
private final EmailRequest request;
|
||||
private final QRecord template;
|
||||
private final QRecord version;
|
||||
private final QRecord brand;
|
||||
@@ -98,6 +99,7 @@ public final class EmailTemplateRenderer
|
||||
*******************************************************************************/
|
||||
public EmailTemplateRenderer(QRecord template, QRecord version, EmailRequest request) throws QException
|
||||
{
|
||||
this.request = request;
|
||||
this.template = template;
|
||||
this.version = version;
|
||||
approved = Boolean.TRUE.equals(template.getValueBoolean("phiApproved")) && version.getValue("id") != null
|
||||
@@ -117,6 +119,21 @@ public final class EmailTemplateRenderer
|
||||
EmailBrands.validate(brand);
|
||||
schema = new VariableSchema(version.getValueString("variablesSchema"));
|
||||
records = new LinkedHashMap<>(request.getRecordVariables());
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM variables must be supplied from caller-visible records"));
|
||||
}
|
||||
values = new LinkedHashMap<>(request.getVariables());
|
||||
category = request.getCategory() == null ? EmailCategory.valueOf(template.getValueString("category")) : request.getCategory();
|
||||
if(category != EmailCategory.TRANSACTIONAL && category != EmailCategory.MARKETING)
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
|
||||
}
|
||||
CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
|
||||
return;
|
||||
}
|
||||
java.util.Set<String> references = new java.util.LinkedHashSet<>();
|
||||
for(String field : List.of("subject", "preheader", "bodySource", "textSource", "compiledHtml"))
|
||||
{
|
||||
@@ -287,6 +304,40 @@ public final class EmailTemplateRenderer
|
||||
*******************************************************************************/
|
||||
public QRecord render(String messageId, String unsubscribe, boolean encrypt)
|
||||
{
|
||||
return (render(messageId, unsubscribe, encrypt, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render typed content for an existing transport, which adds its own layout.
|
||||
*******************************************************************************/
|
||||
public QRecord renderContent(String messageId, String unsubscribe)
|
||||
{
|
||||
return (render(messageId, unsubscribe, false, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share parsing, classification and output limits across both consumers.
|
||||
*******************************************************************************/
|
||||
private QRecord render(String messageId, String unsubscribe, boolean encrypt, boolean decorate)
|
||||
{
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
QRecord rendered = CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
|
||||
String html = EmailMarkdown.render(rendered.getValueString("markdownBody"), brand);
|
||||
if(decorate)
|
||||
{
|
||||
html = EmailLayout.render(html, null, brand, category, unsubscribe);
|
||||
}
|
||||
String text = HtmlToText.convert(html);
|
||||
if((long) rendered.getValueString("subject").getBytes(java.nio.charset.StandardCharsets.UTF_8).length
|
||||
+ html.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template output is too large"));
|
||||
}
|
||||
return (rendered.withValue("htmlBody", html).withValue("textBody", text)
|
||||
.withValue("variablesSnapshot", "{}").withValue("brandId", brand.getValue("id"))
|
||||
.withValue("locale", version.getValue("locale")).withValue("category", category.name()).withValue("phiApproved", false));
|
||||
}
|
||||
Map<String, Serializable> context = new LinkedHashMap<>(values);
|
||||
for(String field : List.of("label", "name", "siteUrl", "logoUrl", "logoDarkUrl", "logoAlt", "postalAddress", "primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "darkPrimaryColor", "darkButtonTextColor", "footerMarkdown"))
|
||||
{
|
||||
@@ -310,8 +361,12 @@ public final class EmailTemplateRenderer
|
||||
}
|
||||
String body = engine.parse(source, schema).render(context, "MARKDOWN".equals(kind) ? EmailTemplateEngine.Context.MARKDOWN : EmailTemplateEngine.Context.HTML, budget, warnings);
|
||||
String preheader = engine.parse(version.getValueString("preheader"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
|
||||
String html = "MARKDOWN".equals(kind) ? EmailLayout.render(EmailMarkdown.render(body, brand), preheader, brand, category, unsubscribe)
|
||||
: EmailLayout.appendFooter(body, brand, category, unsubscribe);
|
||||
String html = "MARKDOWN".equals(kind) ? EmailMarkdown.render(body, brand) : body;
|
||||
if(decorate)
|
||||
{
|
||||
html = "MARKDOWN".equals(kind) ? EmailLayout.render(html, preheader, brand, category, unsubscribe)
|
||||
: EmailLayout.appendFooter(html, brand, category, unsubscribe);
|
||||
}
|
||||
if(approved)
|
||||
{
|
||||
html = restrictImages(html);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point for other qbits to ship their templates. A template is created
|
||||
** once; after that this class never touches it, so people's edits are safe.
|
||||
*******************************************************************************/
|
||||
public final class EmailTemplates
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailTemplates()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the template and an ACTIVE version 1 when no template of that name
|
||||
** exists. The definition is validated like Activate Version (syntax, schema, a
|
||||
** render with the samples, MJML compile); an invalid one is refused with
|
||||
** plain reasons and nothing is written. Returns true only when this call
|
||||
** created the template; the unique name arbitrates concurrent callers.
|
||||
*******************************************************************************/
|
||||
public static boolean ensure(EmailTemplateDefinition definition) throws QException
|
||||
{
|
||||
List<String> reasons = definition.problems();
|
||||
if(!reasons.isEmpty())
|
||||
{
|
||||
throw (new IllegalArgumentException("Template definition refused: " + String.join("; ", reasons)));
|
||||
}
|
||||
return (SystemWrites.call(() -> create(definition)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The checked, transactional creation, run as the qbit.
|
||||
*******************************************************************************/
|
||||
private static boolean create(EmailTemplateDefinition definition) throws QException
|
||||
{
|
||||
if(exists(definition.getName()))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord template = new QRecord().withValue("name", definition.getName()).withValue("label", definition.getLabel())
|
||||
.withValue("category", definition.getCategory().name()).withValue("kind", definition.getKind()).withValue("phiApproved", false)
|
||||
.withValue("ownerQbit", definition.getOwnerQbit()).withValue("editPermissionName", definition.getEditPermissionName());
|
||||
QRecord version = new QRecord().withValue("versionNumber", 1).withValue("status", "ACTIVE").withValue("locale", "en")
|
||||
.withValue("subject", definition.getSubject()).withValue("bodySource", definition.getBody()).withValue("textSource", definition.getTextBody())
|
||||
.withValue("variablesSchema", definition.getVariablesSchema()).withValue("sampleVariables", definition.getSampleVariables());
|
||||
try
|
||||
{
|
||||
EmailVersions.validate(template, version);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template definition refused: " + e.getMessage()));
|
||||
}
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
|
||||
{
|
||||
QRecord stored = SystemWrites.insert(EmailTables.TEMPLATE, List.of(template), tx).getRecords().getFirst();
|
||||
if(stored.getValue("id") == null || stored.getErrors() != null && !stored.getErrors().isEmpty())
|
||||
{
|
||||
tx.rollback();
|
||||
if(exists(definition.getName()))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
throw (new QException("Could not create template " + definition.getName()));
|
||||
}
|
||||
version.withValue("templateId", stored.getValue("id"));
|
||||
QRecord storedVersion = SystemWrites.insert(EmailTables.VERSION, List.of(version), tx).getRecords().getFirst();
|
||||
if(storedVersion.getValue("id") == null || storedVersion.getErrors() != null && !storedVersion.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not create template version for " + definition.getName()));
|
||||
}
|
||||
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", stored.getValue("id")).withValue("activeVersionId", storedVersion.getValue("id")), tx);
|
||||
tx.commit();
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a template of that name is already present.
|
||||
*******************************************************************************/
|
||||
private static boolean exists(String name) throws QException
|
||||
{
|
||||
return (!SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", name)).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ public final class EmailVersions
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE))
|
||||
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE)
|
||||
&& (approval || !EmailPermissionChecker.hasTemplatePermission(true)))
|
||||
{
|
||||
throw (new IllegalArgumentException(approval ? "PHI approval needs email.admin" : "Editing templates needs email.manage"));
|
||||
}
|
||||
@@ -125,9 +126,9 @@ public final class EmailVersions
|
||||
QRecord template = lock(version.getValueInteger("templateId"), tx);
|
||||
requireEdit(template, true);
|
||||
version = requiredVersion(versionId, tx);
|
||||
if("RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")) || "RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Retired versions and MARKETING templates cannot be approved for PHI"));
|
||||
throw (new IllegalArgumentException("Legacy CRM templates, retired versions and MARKETING templates cannot be approved for PHI"));
|
||||
}
|
||||
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id"))
|
||||
.withValue("phiApproved", true).withValue("approvedVersionId", versionId).withValue("phiApprovedBy", actor())
|
||||
@@ -176,6 +177,11 @@ public final class EmailVersions
|
||||
public static void validate(QRecord template, QRecord version) throws QException
|
||||
{
|
||||
requireEdit(template, false);
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
CrmLegacyTemplateRenderer.render(template, version, java.util.Map.of(), null, null);
|
||||
return;
|
||||
}
|
||||
if("MJML".equals(template.getValueString("kind")))
|
||||
{
|
||||
CliMjmlCompiler.rejectIncludes(version.getValueString("bodySource"));
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider payloads become small recipient-level events, never message bodies.
|
||||
*******************************************************************************/
|
||||
public final class EmailEventParser
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Provider identifiers remain scoped to the provider during database matching.
|
||||
*******************************************************************************/
|
||||
public record Event(String externalId, String messageId, String providerMessageId, String type, String bounceType,
|
||||
String recipient, Instant occurred, String detail, String url)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one provider request, including all SendGrid events and SES recipients.
|
||||
*******************************************************************************/
|
||||
public List<Event> parse(String kind, String body, Instant received)
|
||||
{
|
||||
List<Event> events = new ArrayList<>();
|
||||
switch(kind)
|
||||
{
|
||||
case "SES" -> ses(new JSONObject(body), received, events);
|
||||
case "POSTMARK" -> postmark(new JSONObject(body), received, events);
|
||||
case "MAILGUN" -> mailgun(new JSONObject(body).getJSONObject("event-data"), received, events);
|
||||
case "SENDGRID" ->
|
||||
{
|
||||
JSONArray batch = new JSONArray(body);
|
||||
for(int i = 0; i < batch.length(); i++)
|
||||
{
|
||||
sendgrid(batch.getJSONObject(i), received, events);
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unsupported email event kind");
|
||||
}
|
||||
return (events);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SES recipients live under the specific event, with mail.destination fallback for non-suppressive events only.
|
||||
*******************************************************************************/
|
||||
private static void ses(JSONObject envelope, Instant received, List<Event> events)
|
||||
{
|
||||
if(!"Notification".equals(envelope.getString("Type")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
JSONObject payload = new JSONObject(envelope.getString("Message"));
|
||||
String rawType = payload.optString("eventType", payload.optString("notificationType"));
|
||||
String type = switch(rawType)
|
||||
{
|
||||
case "Delivery" -> "DELIVERED";
|
||||
case "Bounce" -> "BOUNCED";
|
||||
case "Complaint" -> "COMPLAINED";
|
||||
case "Reject" -> "REJECTED";
|
||||
case "DeliveryDelay" -> "DEFERRED";
|
||||
case "Open" -> "OPENED";
|
||||
case "Click" -> "CLICKED";
|
||||
default -> null;
|
||||
};
|
||||
if(type == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JSONObject mail = payload.getJSONObject("mail");
|
||||
String key = "DeliveryDelay".equals(rawType) ? "deliveryDelay" : rawType.toLowerCase(Locale.ROOT);
|
||||
JSONObject data = object(payload, key);
|
||||
String bounce = "BOUNCED".equals(type) ? switch(data.optString("bounceType"))
|
||||
{
|
||||
case "Permanent" -> "HARD";
|
||||
case "Transient" -> "SOFT";
|
||||
default -> "UNDETERMINED";
|
||||
} : null;
|
||||
String messageId = null;
|
||||
JSONArray headers = mail.optJSONArray("headers");
|
||||
if(headers != null)
|
||||
{
|
||||
for(int i = 0; i < headers.length(); i++)
|
||||
{
|
||||
JSONObject entry = headers.getJSONObject(i);
|
||||
if("X-QQQ-Email-Id".equalsIgnoreCase(entry.optString("name")))
|
||||
{
|
||||
messageId = entry.optString("value", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONArray recipients = data.optJSONArray(switch(type)
|
||||
{
|
||||
case "BOUNCED" -> "bouncedRecipients";
|
||||
case "COMPLAINED" -> "complainedRecipients";
|
||||
case "DEFERRED" -> "delayedRecipients";
|
||||
default -> "recipients";
|
||||
});
|
||||
if(recipients == null && ("BOUNCED".equals(type) || "COMPLAINED".equals(type)))
|
||||
{
|
||||
// Never widen a bounce or complaint to every destination; the event carries no recipient and is skipped.
|
||||
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, null,
|
||||
time(data.opt("timestamp"), received), null, null));
|
||||
return;
|
||||
}
|
||||
if(recipients == null)
|
||||
{
|
||||
recipients = mail.getJSONArray("destination");
|
||||
}
|
||||
for(int i = 0; i < recipients.length(); i++)
|
||||
{
|
||||
Object recipient = recipients.get(i);
|
||||
String address = recipient instanceof JSONObject row ? row.getString("emailAddress") : recipient.toString();
|
||||
String reason = recipient instanceof JSONObject row ? row.optString("diagnosticCode", data.optString("bounceSubType", null))
|
||||
: data.optString("reason", data.optString("smtpResponse", data.optString("delayType", data.optString("complaintFeedbackType", null))));
|
||||
events.add(new Event(envelope.getString("MessageId"), messageId, mail.getString("messageId"), type, bounce, address,
|
||||
time(data.opt("timestamp"), received), detail(reason), "CLICKED".equals(type) ? data.optString("link", null) : null));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Postmark subscription reactivation is informational, never an unsubscribe.
|
||||
*******************************************************************************/
|
||||
private static void postmark(JSONObject data, Instant received, List<Event> events)
|
||||
{
|
||||
String type = switch(data.getString("RecordType"))
|
||||
{
|
||||
case "Delivery" -> "DELIVERED";
|
||||
case "Bounce" -> "BOUNCED";
|
||||
case "SpamComplaint" -> "COMPLAINED";
|
||||
case "Open" -> "OPENED";
|
||||
case "Click" -> "CLICKED";
|
||||
case "SubscriptionChange" -> data.optBoolean("SuppressSending", false) ? "UNSUBSCRIBED" : null;
|
||||
default -> null;
|
||||
};
|
||||
if(type == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String bounce = "BOUNCED".equals(type) ? switch(data.optString("Type"))
|
||||
{
|
||||
case "HardBounce", "SpamNotification", "SpamComplaint", "BadEmailAddress", "ManuallyDeactivated" -> "HARD";
|
||||
case "SoftBounce", "Transient", "DnsError", "MailboxFull" -> "SOFT";
|
||||
default -> "UNDETERMINED";
|
||||
} : null;
|
||||
Object date = data.opt("DeliveredAt");
|
||||
for(String field : List.of("BouncedAt", "ReceivedAt", "ChangedAt"))
|
||||
{
|
||||
if(data.has(field))
|
||||
{
|
||||
date = data.get(field);
|
||||
}
|
||||
}
|
||||
String id = data.has("ID") ? data.get("RecordType") + ":" + data.get("ID") : fingerprint(data);
|
||||
events.add(new Event(id, null, data.optString("MessageID", null), type, bounce,
|
||||
data.optString("Recipient", data.optString("Email", null)), time(date, received),
|
||||
detail(data.optString("Description", data.optString("Details", data.optString("SuppressionReason", null)))),
|
||||
"CLICKED".equals(type) ? data.optString("OriginalLink", null) : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SendGrid retains the provider message id prefix before its delivery suffix.
|
||||
*******************************************************************************/
|
||||
private static void sendgrid(JSONObject data, Instant received, List<Event> events)
|
||||
{
|
||||
String type = switch(data.getString("event"))
|
||||
{
|
||||
case "delivered" -> "DELIVERED";
|
||||
case "bounce" -> "BOUNCED";
|
||||
case "dropped" -> "REJECTED";
|
||||
case "deferred" -> "DEFERRED";
|
||||
case "spamreport" -> "COMPLAINED";
|
||||
case "open" -> "OPENED";
|
||||
case "click" -> "CLICKED";
|
||||
case "unsubscribe", "group_unsubscribe" -> "UNSUBSCRIBED";
|
||||
default -> null;
|
||||
};
|
||||
if(type == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String bounce = "BOUNCED".equals(type) ? ("blocked".equals(data.optString("type")) ? "SOFT" : "HARD") : null;
|
||||
String providerMessage = data.optString("sg_message_id", null);
|
||||
if(providerMessage != null && providerMessage.contains("."))
|
||||
{
|
||||
providerMessage = providerMessage.substring(0, providerMessage.indexOf('.'));
|
||||
}
|
||||
events.add(new Event(data.optString("sg_event_id", fingerprint(data)),
|
||||
data.optString("qqq_email_id", object(data, "custom_args").optString("qqq_email_id", null)), providerMessage, type, bounce,
|
||||
data.optString("email", null), time(data.opt("timestamp"), received), detail(data.optString("reason", data.optString("response", null))),
|
||||
"CLICKED".equals(type) ? data.optString("url", null) : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mailgun MIME ids are normalized identically to the outbound transport.
|
||||
*******************************************************************************/
|
||||
private static void mailgun(JSONObject data, Instant received, List<Event> events)
|
||||
{
|
||||
String type = switch(data.getString("event"))
|
||||
{
|
||||
case "delivered" -> "DELIVERED";
|
||||
case "failed" -> "BOUNCED";
|
||||
case "complained" -> "COMPLAINED";
|
||||
case "opened" -> "OPENED";
|
||||
case "clicked" -> "CLICKED";
|
||||
case "unsubscribed" -> "UNSUBSCRIBED";
|
||||
default -> null;
|
||||
};
|
||||
if(type == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String bounce = "BOUNCED".equals(type) ? switch(data.optString("severity"))
|
||||
{
|
||||
case "permanent" -> "HARD";
|
||||
case "temporary" -> "SOFT";
|
||||
default -> "UNDETERMINED";
|
||||
} : null;
|
||||
JSONObject variables = object(data, "user-variables");
|
||||
String providerMessage = object(object(data, "message"), "headers").optString("message-id", null);
|
||||
if(providerMessage != null)
|
||||
{
|
||||
providerMessage = providerMessage.replaceAll("^<|>$", "");
|
||||
}
|
||||
events.add(new Event(data.optString("id", fingerprint(data)), variables.optString("qqq_email_id", variables.optString("v:qqq_email_id", null)),
|
||||
providerMessage, type, bounce, data.optString("recipient", null), time(data.opt("timestamp"), received),
|
||||
detail(data.optString("reason", object(data, "delivery-status").optString("message", null))), "CLICKED".equals(type) ? data.optString("url", null) : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional nested objects contain no executable data.
|
||||
*******************************************************************************/
|
||||
private static JSONObject object(JSONObject parent, String key)
|
||||
{
|
||||
JSONObject value = parent.optJSONObject(key);
|
||||
return (value == null ? new JSONObject() : value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fall back to receipt time only when the provider supplied no timestamp.
|
||||
*******************************************************************************/
|
||||
private static Instant time(Object value, Instant fallback)
|
||||
{
|
||||
if(value == null || JSONObject.NULL.equals(value))
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
if(value instanceof Number number)
|
||||
{
|
||||
return (Instant.ofEpochMilli((long) (number.doubleValue() * 1000)));
|
||||
}
|
||||
return (OffsetDateTime.parse(value.toString()).toInstant());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fallback identity for providers without a distinct event identifier.
|
||||
*******************************************************************************/
|
||||
private static String fingerprint(JSONObject data)
|
||||
{
|
||||
return (WebhookVerification.hash(data.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mask complete addresses before truncation, including quoted local parts.
|
||||
*******************************************************************************/
|
||||
public static String detail(String reason)
|
||||
{
|
||||
if(reason == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String masked = reason.replaceAll("(?i)(?:\"[^\"]*\"|[^\\s<>(),;:]+)@[^\\s<>(),;:]+", "[address]");
|
||||
return (masked.substring(0, Math.min(500, masked.length())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.delivery.EmailAlerts;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded, replica-safe database transactions apply inbox rows exactly once.
|
||||
** No network or external effects occur while the row's provider is locked.
|
||||
*******************************************************************************/
|
||||
public final class EmailInboxProcessor
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(EmailInboxProcessor.class);
|
||||
|
||||
private static final int MAX_ATTEMPTS = 6;
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll due rows; transaction rollback releases claims after a worker crash.
|
||||
*******************************************************************************/
|
||||
public int runOnce() throws QException
|
||||
{
|
||||
int processed = 0;
|
||||
for(QRecord row : SystemWrites.query(EmailTables.INBOX, EmailStore.equal("status", "PENDING")
|
||||
.withCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(EmailRuntime.config().getBatchSize())))
|
||||
{
|
||||
if(process(row))
|
||||
{
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
return (processed);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read eligibility after the lock, including the observed attempt number.
|
||||
*******************************************************************************/
|
||||
private static boolean due(QRecord current, QRecord observed)
|
||||
{
|
||||
return (current != null && "PENDING".equals(current.getValueString("status"))
|
||||
&& Objects.equals(current.getValue("attempts"), observed.getValue("attempts"))
|
||||
&& current.getValueInstant("nextAttemptDate") != null && !current.getValueInstant("nextAttemptDate").isAfter(EmailRuntime.clock().instant()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All event, message, suppression and receipt changes commit together.
|
||||
*******************************************************************************/
|
||||
private boolean process(QRecord observed) throws QException
|
||||
{
|
||||
boolean complaint = false;
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
QRecord provider = EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
|
||||
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
|
||||
if(!due(row, observed))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int applied = 0;
|
||||
int unmatched = 0;
|
||||
int skipped = 0;
|
||||
for(EmailEventParser.Event event : new EmailEventParser().parse(row.getValueString("kind"), row.getValueString("payloadJson"), row.getValueInstant("receivedDate")))
|
||||
{
|
||||
if(("OPENED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackOpens")))
|
||||
|| ("CLICKED".equals(event.type()) && !Boolean.TRUE.equals(provider.getValueBoolean("trackClicks"))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord message = match(event, provider.getValueInteger("id"), tx);
|
||||
if(message == null || "SENDING".equals(message.getValueString("status")))
|
||||
{
|
||||
unmatched++;
|
||||
continue;
|
||||
}
|
||||
String recipient = normalize(event.recipient());
|
||||
if(recipient == null || !recipients(message).contains(recipient))
|
||||
{
|
||||
LOG.info("Ignoring an email event whose recipient is not on the message", logPair("messageId", message.getValue("id")));
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if(Boolean.TRUE.equals(message.getValueBoolean("phiApproved")) && List.of("OPENED", "CLICKED").contains(event.type()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = provider.getValueInteger("id") + ":" + WebhookVerification.hash((event.externalId() + "\n" + event.type() + "\n"
|
||||
+ recipient).getBytes(StandardCharsets.UTF_8));
|
||||
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
|
||||
if(stored == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
applied++;
|
||||
updateMessage(message, stored, tx);
|
||||
if("COMPLAINED".equals(event.type()) || ("BOUNCED".equals(event.type()) && "HARD".equals(event.bounceType())))
|
||||
{
|
||||
EmailTrackingStore.suppress(message, stored, "ALL", "COMPLAINED".equals(event.type()) ? "COMPLAINT" : "HARD_BOUNCE", tx);
|
||||
complaint |= "COMPLAINED".equals(event.type());
|
||||
}
|
||||
else if("UNSUBSCRIBED".equals(event.type()))
|
||||
{
|
||||
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
|
||||
}
|
||||
}
|
||||
|
||||
int attempts = row.getValueInteger("attempts") + 1;
|
||||
boolean retry = unmatched > 0 && attempts < MAX_ATTEMPTS;
|
||||
List<String> notes = new ArrayList<>();
|
||||
if(unmatched > 0)
|
||||
{
|
||||
notes.add(unmatched + (unmatched == 1 ? " event did not match a message" : " events did not match a message"));
|
||||
}
|
||||
if(skipped > 0)
|
||||
{
|
||||
notes.add(skipped + (skipped == 1 ? " event was skipped (recipient not on the message)" : " events were skipped (recipient not on the message)"));
|
||||
}
|
||||
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("status", retry ? "PENDING" : (applied == 0 && unmatched == 0 ? "IGNORED" : "PROCESSED")).withValue("attempts", attempts)
|
||||
.withValue("processedDate", retry ? null : EmailRuntime.clock().instant())
|
||||
.withValue("nextAttemptDate", retry ? EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts)) : null)
|
||||
.withValue("lastError", notes.isEmpty() ? null : String.join("; ", notes)), tx);
|
||||
tx.commit();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
fail(observed);
|
||||
return (false);
|
||||
}
|
||||
if(complaint)
|
||||
{
|
||||
complaintAlert();
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical mailbox, or null when the provider supplied none that is valid.
|
||||
*******************************************************************************/
|
||||
private static String normalize(String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (EmailTrackingStore.address(value));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical to, cc and bcc mailboxes of a message; only these may be
|
||||
** suppressed by an event matched to it.
|
||||
*******************************************************************************/
|
||||
private static Set<String> recipients(QRecord message)
|
||||
{
|
||||
Set<String> set = new HashSet<>();
|
||||
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
|
||||
{
|
||||
String value = message.getValueString(field);
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
for(InternetAddress address : InternetAddress.parse(value, false))
|
||||
{
|
||||
set.add(address.getAddress().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
catch(jakarta.mail.internet.AddressException e)
|
||||
{
|
||||
// A malformed stored envelope contributes no recipients.
|
||||
}
|
||||
}
|
||||
return (set);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match our id first, then the provider id; neither may cross providers.
|
||||
*******************************************************************************/
|
||||
private static QRecord match(EmailEventParser.Event event, Integer providerId, QBackendTransaction tx) throws QException
|
||||
{
|
||||
if(event.messageId() != null && event.messageId().matches("[0-9]{1,10}") && Long.parseLong(event.messageId()) <= Integer.MAX_VALUE)
|
||||
{
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(event.messageId()), tx);
|
||||
if(message != null && providerId.equals(message.getValueInteger("providerId")))
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
}
|
||||
if(event.providerMessageId() == null || event.providerMessageId().isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("providerId", providerId)
|
||||
.withCriteria("providerMessageId", QCriteriaOperator.EQUALS, event.providerMessageId()), tx);
|
||||
return (rows.size() == 1 ? rows.getFirst() : null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complaint outranks hard bounce, which outranks delivery; never regress.
|
||||
*******************************************************************************/
|
||||
private static void updateMessage(QRecord message, QRecord event, QBackendTransaction tx) throws QException
|
||||
{
|
||||
String type = event.getValueString("eventType");
|
||||
String status = message.getValueString("status");
|
||||
String field = switch(type)
|
||||
{
|
||||
case "DELIVERED" -> "SENT".equals(status) ? "deliveredDate" : null;
|
||||
case "BOUNCED" -> "HARD".equals(event.getValueString("bounceType")) && List.of("SENT", "DELIVERED").contains(status) ? "bouncedDate" : null;
|
||||
case "COMPLAINED" -> List.of("SENT", "DELIVERED", "BOUNCED").contains(status) ? "complainedDate" : null;
|
||||
default -> null;
|
||||
};
|
||||
if(field != null)
|
||||
{
|
||||
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", message.getValue("id")).withValue("status", type)
|
||||
.withValue(field, event.getValue("occurredDate")), tx);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** After rollback, only the still-due observed attempt may consume retry budget.
|
||||
*******************************************************************************/
|
||||
private static void fail(QRecord observed) throws QException
|
||||
{
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailTrackingStore.lock(observed.getValueInteger("providerId"), tx);
|
||||
QRecord row = EmailStore.get(EmailTables.INBOX, observed.getValueInteger("id"), tx);
|
||||
if(due(row, observed))
|
||||
{
|
||||
int attempts = row.getValueInteger("attempts") + 1;
|
||||
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts)
|
||||
.withValue("status", attempts >= MAX_ATTEMPTS ? "FAILED" : "PENDING").withValue("lastError", "Email event processing failed; check message matching and payload validity.")
|
||||
.withValue("nextAttemptDate", attempts >= MAX_ATTEMPTS ? null : EmailRuntime.clock().instant().plusSeconds(60L * EmailRuntime.config().retryMinutesAfterAttempt(attempts))), tx);
|
||||
tx.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Reprocess action rechecks FAILED under the same transaction lock.
|
||||
*******************************************************************************/
|
||||
public static boolean reprocess(Integer id) throws QException
|
||||
{
|
||||
QRecord selected = EmailStore.get(EmailTables.INBOX, id, null);
|
||||
if(selected == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailTrackingStore.lock(selected.getValueInteger("providerId"), tx);
|
||||
QRecord row = EmailStore.get(EmailTables.INBOX, id, tx);
|
||||
if(!"FAILED".equals(row.getValueString("status")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
EmailStore.update(EmailTables.INBOX, new QRecord().withValue("id", id).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptDate", EmailRuntime.clock().instant()).withValue("lastError", null).withValue("processedDate", null), tx);
|
||||
tx.commit();
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional alerting cannot turn a committed event into a failed inbox row.
|
||||
*******************************************************************************/
|
||||
private static void complaintAlert()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.backend.core.alerts.QAlerts");
|
||||
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventType", "COMPLAINED")
|
||||
.withCriteria("occurredDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, EmailRuntime.clock().instant().minusSeconds(86400))
|
||||
.withCriteria("occurredDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant())) > 3)
|
||||
{
|
||||
EmailAlerts.raise(EmailAlerts.COMPLAINT, 0);
|
||||
}
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
// Alert integration is optional; event data is already committed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DB-only receipt, event deduplication and suppression writes.
|
||||
*******************************************************************************/
|
||||
public final class EmailTrackingStore
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Distinguish an authenticated retry from reuse of a Mailgun nonce.
|
||||
*******************************************************************************/
|
||||
public enum Receipt
|
||||
{
|
||||
RECEIVED, DUPLICATE, REPLAY
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailTrackingStore()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize address upserts across providers, then acquire the dispatcher lock.
|
||||
** Every tracking writer uses this order and does no network I/O while locked.
|
||||
*******************************************************************************/
|
||||
public static QRecord lock(Integer providerId, QBackendTransaction tx) throws QException
|
||||
{
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(1), tx);
|
||||
if(providers.isEmpty())
|
||||
{
|
||||
throw (new QException("Email provider unavailable"));
|
||||
}
|
||||
EmailStore.lockProvider(providers.getFirst().getValueInteger("id"), tx);
|
||||
QRecord provider = EmailStore.lockProvider(providerId, tx);
|
||||
if(provider == null)
|
||||
{
|
||||
throw (new QException("Email provider unavailable"));
|
||||
}
|
||||
return (provider);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider lock avoids expected duplicate SQL errors (and payload logging).
|
||||
** Unique constraints remain the final database guard.
|
||||
*******************************************************************************/
|
||||
public static Receipt receive(QRecord provider, String externalId, String nonce, byte[] bytes, String note) throws QException
|
||||
{
|
||||
Integer providerId = provider.getValueInteger("id");
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailStore.lockProvider(providerId, tx);
|
||||
QQueryFilter filter = EmailStore.equal("providerId", providerId);
|
||||
if(nonce != null && SystemWrites.count(EmailTables.INBOX, filter.clone().withCriteria("nonce", QCriteriaOperator.EQUALS, nonce), tx) > 0)
|
||||
{
|
||||
return (Receipt.REPLAY);
|
||||
}
|
||||
if(SystemWrites.count(EmailTables.INBOX, filter.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId), tx) > 0)
|
||||
{
|
||||
return (Receipt.DUPLICATE);
|
||||
}
|
||||
var now = EmailRuntime.clock().instant();
|
||||
QRecord row = new QRecord().withValue("providerId", providerId).withValue("kind", provider.getValue("kind"))
|
||||
.withValue("externalEventId", externalId).withValue("nonce", nonce).withValue("status", note == null ? "PENDING" : "IGNORED")
|
||||
.withValue("attempts", 0).withValue("nextAttemptDate", note == null ? now : null).withValue("lastError", note)
|
||||
.withValue("payloadJson", new String(bytes, StandardCharsets.UTF_8)).withValue("payloadSha256", WebhookVerification.hash(bytes))
|
||||
.withValue("receivedDate", now).withValue("processedDate", note == null ? null : now);
|
||||
copySecurity(provider, row);
|
||||
insert(EmailTables.INBOX, row, tx);
|
||||
tx.commit();
|
||||
return (Receipt.RECEIVED);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
// Re-query after rollback: a unique violation aborts a PostgreSQL transaction.
|
||||
if(nonce != null && SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
|
||||
.withCriteria("nonce", QCriteriaOperator.EQUALS, nonce)) > 0)
|
||||
{
|
||||
return (Receipt.REPLAY);
|
||||
}
|
||||
if(SystemWrites.count(EmailTables.INBOX, EmailStore.equal("providerId", providerId)
|
||||
.withCriteria("externalEventId", QCriteriaOperator.EQUALS, externalId)) > 0)
|
||||
{
|
||||
return (Receipt.DUPLICATE);
|
||||
}
|
||||
throw (new QException("Email inbox write failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail atomically without exposing input values or database diagnostics.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord row, QBackendTransaction tx) throws QException
|
||||
{
|
||||
List<QRecord> records = SystemWrites.insert(table, List.of(row), tx).getRecords();
|
||||
if(records.isEmpty() || records.getFirst().getValueInteger("id") == null || (records.getFirst().getErrors() != null && !records.getFirst().getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("Email tracking write failed"));
|
||||
}
|
||||
return (records.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve host tenant security on child and suppression records.
|
||||
*******************************************************************************/
|
||||
public static void copySecurity(QRecord source, QRecord target)
|
||||
{
|
||||
for(var field : EmailRuntime.config().getSecurityFields())
|
||||
{
|
||||
target.withValue(field.getName(), source.getValue(field.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a recipient event once even across overlapping provider batches.
|
||||
*******************************************************************************/
|
||||
public static QRecord event(QRecord message, EmailEventParser.Event event, String key, QBackendTransaction tx) throws QException
|
||||
{
|
||||
if(SystemWrites.count(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx) > 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord row = new QRecord().withValue("messageId", message.getValue("id")).withValue("providerId", message.getValue("providerId"))
|
||||
.withValue("eventType", event.type()).withValue("bounceType", event.bounceType()).withValue("recipient", address(event.recipient()))
|
||||
.withValue("occurredDate", event.occurred()).withValue("detail", event.detail()).withValue("url", "CLICKED".equals(event.type()) ? event.url() : null)
|
||||
.withValue("eventKey", key);
|
||||
copySecurity(message, row);
|
||||
return (insert(EmailTables.EVENT, row, tx));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upsert without weakening a complaint or shortening an existing suppression.
|
||||
** All callers hold the common tracking lock throughout their transaction.
|
||||
*******************************************************************************/
|
||||
public static void suppress(QRecord message, QRecord event, String scope, String reason, QBackendTransaction tx) throws QException
|
||||
{
|
||||
if("UNSUBSCRIBE".equals(reason) && !List.of("MARKETING", "NOTIFICATION").contains(scope == null ? "" : scope))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String address = event.getValueString("recipient");
|
||||
List<QRecord> existing = SystemWrites.query(EmailTables.SUPPRESSION, EmailStore.equal("address", address)
|
||||
.withCriteria("scope", QCriteriaOperator.EQUALS, scope), tx);
|
||||
QRecord row = new QRecord().withValue("address", address).withValue("scope", scope).withValue("reason", reason)
|
||||
.withValue("sourceMessageId", message.getValue("id")).withValue("sourceEventId", event.getValue("id")).withValue("expiresDate", null);
|
||||
copySecurity(message, row);
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
row.withValue("createdDate", EmailRuntime.clock().instant());
|
||||
insert(EmailTables.SUPPRESSION, row, tx);
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord old = existing.getFirst();
|
||||
if("COMPLAINT".equals(old.getValueString("reason")))
|
||||
{
|
||||
row.withValue("reason", "COMPLAINT");
|
||||
}
|
||||
row.withValue("id", old.getValue("id"));
|
||||
EmailStore.update(EmailTables.SUPPRESSION, row, tx);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical mailbox for event deduplication and suppression lookup.
|
||||
*******************************************************************************/
|
||||
public static String address(String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
InternetAddress address = new InternetAddress(value, true);
|
||||
address.validate();
|
||||
return (address.getAddress().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Email event recipient is invalid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateEngine;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stateless token validation and atomic, idempotent category opt-out.
|
||||
*******************************************************************************/
|
||||
public final class EmailUnsubscribe
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the signed message and its single recipient, without writes.
|
||||
*******************************************************************************/
|
||||
public QRecord resolve(String token) throws QException
|
||||
{
|
||||
return (resolve(token, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck the binding against the same transaction used for suppression.
|
||||
*******************************************************************************/
|
||||
private QRecord resolve(String token, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
JSONObject payload = EmailUnsubscribeLinks.verify(token);
|
||||
if(payload == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, Integer.valueOf(payload.getString("messageId")), transaction);
|
||||
if(message == null || !payload.getString("category").equals(message.getValueString("category")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
|
||||
String hash = payload.getString("addressHash");
|
||||
// Safe legacy links remain valid only after the single-mailbox check above.
|
||||
String legacyEnvelope = message.getValueString("toAddresses") + "," + message.getValueString("ccAddresses") + "," + message.getValueString("bccAddresses");
|
||||
boolean matches = WebhookVerification.equal(hash, WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8)))
|
||||
|| WebhookVerification.equal(hash, WebhookVerification.hash(legacyEnvelope.getBytes(StandardCharsets.UTF_8)));
|
||||
return (matches ? message : null);
|
||||
}
|
||||
catch(IllegalArgumentException | org.json.JSONException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt out only the signed mailbox; reject changed or multi-recipient envelopes.
|
||||
** Never display those addresses on the public page or emit them to logs.
|
||||
*******************************************************************************/
|
||||
public void unsubscribe(String token) throws QException
|
||||
{
|
||||
QRecord initial = resolve(token);
|
||||
if(initial == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid unsubscribe link"));
|
||||
}
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailTrackingStore.lock(initial.getValueInteger("providerId"), tx);
|
||||
QRecord message = resolve(token, tx);
|
||||
if(message == null || !initial.getValueInteger("providerId").equals(message.getValueInteger("providerId")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid unsubscribe link"));
|
||||
}
|
||||
String recipient = EmailUnsubscribeLinks.recipient(message.getValueString("toAddresses"), message.getValueString("ccAddresses"), message.getValueString("bccAddresses"));
|
||||
String key = "unsubscribe:" + message.getValueInteger("id") + ":" + WebhookVerification.hash(recipient.getBytes(StandardCharsets.UTF_8));
|
||||
var event = new EmailEventParser.Event(key, message.getValueString("id"), null, "UNSUBSCRIBED", null, recipient, EmailRuntime.clock().instant(), null, null);
|
||||
QRecord stored = EmailTrackingStore.event(message, event, key, tx);
|
||||
if(stored == null)
|
||||
{
|
||||
stored = com.kingsrook.qbits.email.support.SystemWrites.query(EmailTables.EVENT, EmailStore.equal("eventKey", key), tx).getFirst();
|
||||
}
|
||||
EmailTrackingStore.suppress(message, stored, message.getValueString("category"), "UNSUBSCRIBE", tx);
|
||||
tx.commit();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-contained accessible page; an empty form action posts to this same URL.
|
||||
*******************************************************************************/
|
||||
public String page(QRecord message, boolean confirmed) throws QException
|
||||
{
|
||||
QRecord brand = message.getValueInteger("brandId") == null ? null : EmailStore.get(EmailTables.BRAND, message.getValueInteger("brandId"), null);
|
||||
String label = brand == null || brand.getValueString("label") == null ? EmailRuntime.config().getAppLabel() : brand.getValueString("label");
|
||||
String category = "MARKETING".equals(message.getValueString("category")) ? "Marketing" : "Notification";
|
||||
String question = confirmed ? "You are unsubscribed from " + category + " email from " + label + "."
|
||||
: "Unsubscribe from " + category + " email from " + label + "?";
|
||||
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
||||
+ "<meta name=\"color-scheme\" content=\"light dark\"><title>Email preferences</title><style>"
|
||||
+ ":root{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;padding:20px;font:18px/1.5 system-ui;background:#fff;color:#172033}"
|
||||
+ "main{max-width:560px;margin:40px auto;overflow-wrap:anywhere}h1{font-size:1.5rem}button{max-width:100%;padding:12px 20px;font:inherit;background:#174ea6;color:#fff;border:0;border-radius:6px;cursor:pointer}"
|
||||
+ "@media(prefers-color-scheme:dark){body{background:#111827;color:#f9fafb}button{background:#a9c7ff;color:#111827}}</style></head><body><main><p>"
|
||||
+ EmailTemplateEngine.escape(label) + "</p><h1>" + EmailTemplateEngine.escape(question) + "</h1>"
|
||||
+ (confirmed ? "" : "<form method=\"post\"><input type=\"hidden\" name=\"confirm\" value=\"unsubscribe\"><button type=\"submit\">Unsubscribe</button></form>")
|
||||
+ "</main></body></html>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate Mailgun; the durable inbox separately arbitrates nonce replay.
|
||||
*******************************************************************************/
|
||||
public final class MailgunVerifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Verify timestamp, token and hexadecimal HMAC without retaining the key.
|
||||
*******************************************************************************/
|
||||
public boolean verify(JSONObject payload, String secret, Instant now)
|
||||
{
|
||||
if(secret == null || secret.isBlank())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject signed = payload.getJSONObject("signature");
|
||||
String timestamp = signed.get("timestamp").toString();
|
||||
String token = signed.getString("token");
|
||||
if(token.isBlank() || token.length() > 500 || !WebhookVerification.fresh(timestamp, now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
String expected = HexFormat.of().formatHex(mac.doFinal((timestamp + token).getBytes(StandardCharsets.UTF_8)));
|
||||
return (WebhookVerification.equal(expected, signed.getString("signature")));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Postmark authenticates with fixed username postmark and a resolved password.
|
||||
*******************************************************************************/
|
||||
public final class PostmarkVerifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Verify HTTP Basic credentials without logging or returning either value.
|
||||
*******************************************************************************/
|
||||
public boolean verify(String authorization, String secret)
|
||||
{
|
||||
if(secret == null || secret.isBlank() || authorization == null || !authorization.regionMatches(true, 0, "Basic ", 0, 6))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
String credentials = new String(Base64.getDecoder().decode(authorization.substring(6)), StandardCharsets.UTF_8);
|
||||
return (WebhookVerification.equal("postmark:" + secret, credentials));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify SendGrid's timestamp and raw body with its DER encoded EC public key.
|
||||
*******************************************************************************/
|
||||
public final class SendGridVerifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The timestamp bytes precede the unchanged request body.
|
||||
*******************************************************************************/
|
||||
public boolean verify(byte[] body, String signature, String timestamp, String secret, Instant now)
|
||||
{
|
||||
if(secret == null || secret.isBlank() || signature == null || !WebhookVerification.fresh(timestamp, now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(secret)));
|
||||
Signature verifier = Signature.getInstance("SHA256withECDSA");
|
||||
verifier.initVerify(key);
|
||||
verifier.update(timestamp.getBytes(StandardCharsets.UTF_8));
|
||||
verifier.update(body);
|
||||
return (verifier.verify(Base64.getDecoder().decode(signature)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Signature;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.email.transport.EmailHttp;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SNS verification with HTTPS AWS-only certificate retrieval, memory caching (including
|
||||
** a five-minute cache of fetch failures) and a freshness window on the signed Timestamp.
|
||||
*******************************************************************************/
|
||||
public final class SnsVerifier
|
||||
{
|
||||
private final EmailHttp http;
|
||||
private static final Duration MAX_AGE = Duration.ofHours(1);
|
||||
private static final Duration MAX_FUTURE = Duration.ofMinutes(5);
|
||||
private static final Duration FAILURE_TTL = Duration.ofMinutes(5);
|
||||
|
||||
private final Map<String, X509Certificate> certificates = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> failures = new ConcurrentHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Default bounded HTTPS transport without redirects.
|
||||
*******************************************************************************/
|
||||
public SnsVerifier()
|
||||
{
|
||||
this(EmailHttp::sendJdk);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject HTTP for deterministic certificate and confirmation tests.
|
||||
*******************************************************************************/
|
||||
public SnsVerifier(EmailHttp http)
|
||||
{
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict URL boundary applied before every network operation.
|
||||
*******************************************************************************/
|
||||
public static boolean allowedUrl(String value, boolean certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
URI uri = URI.create(value);
|
||||
return ("https".equals(uri.getScheme()) && uri.getHost() != null
|
||||
&& uri.getHost().matches("^sns\\.[a-z0-9-]+\\.amazonaws\\.com(\\.cn)?$")
|
||||
&& uri.getUserInfo() == null && (uri.getPort() == -1 || uri.getPort() == 443) && uri.getFragment() == null
|
||||
&& (!certificate || (uri.getPath().endsWith(".pem") && uri.getQuery() == null)));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** AWS's fixed order includes the final newline and optional Subject only.
|
||||
*******************************************************************************/
|
||||
public static String canonical(JSONObject payload)
|
||||
{
|
||||
String type = payload.getString("Type");
|
||||
List<String> fields;
|
||||
if("Notification".equals(type))
|
||||
{
|
||||
fields = payload.has("Subject") ? List.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type")
|
||||
: List.of("Message", "MessageId", "Timestamp", "TopicArn", "Type");
|
||||
}
|
||||
else if("SubscriptionConfirmation".equals(type) || "UnsubscribeConfirmation".equals(type))
|
||||
{
|
||||
fields = List.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new IllegalArgumentException("Unsupported SNS envelope"));
|
||||
}
|
||||
StringBuilder value = new StringBuilder();
|
||||
for(String field : fields)
|
||||
{
|
||||
value.append(field).append('\n').append(payload.getString(field)).append('\n');
|
||||
}
|
||||
return (value.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate the topic and signature; no confirmation before success.
|
||||
*******************************************************************************/
|
||||
public boolean verify(JSONObject payload, String topic, Instant now) throws IOException, InterruptedException
|
||||
{
|
||||
try
|
||||
{
|
||||
String url = payload.getString("SigningCertURL");
|
||||
if(topic == null || topic.isBlank() || !topic.equals(payload.getString("TopicArn")) || !allowedUrl(url, true))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String algorithm = switch(payload.getString("SignatureVersion"))
|
||||
{
|
||||
case "1" -> "SHA1withRSA";
|
||||
case "2" -> "SHA256withRSA";
|
||||
default -> null;
|
||||
};
|
||||
if(algorithm == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Instant stamp = Instant.parse(payload.getString("Timestamp"));
|
||||
if(stamp.isBefore(now.minus(MAX_AGE)) || stamp.isAfter(now.plus(MAX_FUTURE)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String canonical = canonical(payload);
|
||||
X509Certificate certificate = certificates.get(url);
|
||||
if(certificate == null || certificate.getNotAfter().toInstant().isBefore(now))
|
||||
{
|
||||
Instant failed = failures.get(url);
|
||||
if(failed != null && failed.plus(FAILURE_TTL).isAfter(now))
|
||||
{
|
||||
throw (new IOException("SNS verification service unavailable"));
|
||||
}
|
||||
byte[] pem;
|
||||
try
|
||||
{
|
||||
pem = get(url).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
if(failures.size() >= 64)
|
||||
{
|
||||
failures.clear();
|
||||
}
|
||||
failures.put(url, now);
|
||||
throw (e);
|
||||
}
|
||||
certificate = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(pem));
|
||||
certificate.checkValidity(Date.from(now));
|
||||
if(certificates.size() >= 64)
|
||||
{
|
||||
certificates.clear();
|
||||
}
|
||||
certificates.put(url, certificate);
|
||||
}
|
||||
certificate.checkValidity(Date.from(now));
|
||||
Signature verifier = Signature.getInstance(algorithm);
|
||||
verifier.initVerify(certificate.getPublicKey());
|
||||
verifier.update(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
return (verifier.verify(Base64.getDecoder().decode(payload.getString("Signature"))));
|
||||
}
|
||||
catch(IOException | InterruptedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller invokes this only after verifying the signed confirmation envelope.
|
||||
*******************************************************************************/
|
||||
public void confirm(JSONObject payload) throws IOException, InterruptedException
|
||||
{
|
||||
String url = payload.getString("SubscribeURL");
|
||||
if(!allowedUrl(url, false))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid SNS confirmation URL"));
|
||||
}
|
||||
get(url);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed request timeout, no redirects, and no provider response diagnostics.
|
||||
*******************************************************************************/
|
||||
private String get(String url) throws IOException, InterruptedException
|
||||
{
|
||||
var response = http.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(30)).GET().build());
|
||||
if(response.statusCode() != 200 || response.body() == null || response.body().length() > 65536)
|
||||
{
|
||||
throw (new IOException("SNS verification service unavailable"));
|
||||
}
|
||||
return (response.body());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.tracking;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small shared verification primitives. Errors never retain request data.
|
||||
*******************************************************************************/
|
||||
public final class WebhookVerification
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhookVerification()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only an allowlisted webhook secret; null means unavailable.
|
||||
*******************************************************************************/
|
||||
public static String secret(QRecord provider)
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
String name = provider.getValueString("webhookSecretEnvName");
|
||||
try
|
||||
{
|
||||
if(name != null && EmailQBitConfig.ENV_NAME.matcher(name).matches() && config.effectiveAllowedEnvNames(provider.getValueString("kind")).contains(name))
|
||||
{
|
||||
String value = config.getSecretResolver().apply(name);
|
||||
return (value == null || value.isBlank() ? null : value);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare bytes without data-dependent early exits.
|
||||
*******************************************************************************/
|
||||
public static boolean equal(String expected, String actual)
|
||||
{
|
||||
return (expected != null && actual != null && MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject timestamps outside five minutes, including future timestamps.
|
||||
*******************************************************************************/
|
||||
public static boolean fresh(String timestamp, Instant now)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant time = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
return (!time.isBefore(now.minusSeconds(300)) && !time.isAfter(now.plusSeconds(300)));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable payload and event fingerprints, never payload diagnostics.
|
||||
*******************************************************************************/
|
||||
public static String hash(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 unavailable"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import java.util.Map;
|
||||
public class EmailEnvelope
|
||||
{
|
||||
private String messageId;
|
||||
private String rfcMessageId;
|
||||
private String from;
|
||||
private List<String> to = List.of();
|
||||
private List<String> cc = List.of();
|
||||
@@ -62,6 +63,27 @@ public class EmailEnvelope
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the persisted RFC 5322 Message-ID, distinct from the outbox row id.
|
||||
*******************************************************************************/
|
||||
public String getRfcMessageId()
|
||||
{
|
||||
return (rfcMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the persisted RFC 5322 Message-ID.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withRfcMessageId(String rfcMessageId)
|
||||
{
|
||||
this.rfcMessageId = rfcMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for from.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -30,10 +30,10 @@ import java.time.Duration;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible HTTP seam; no provider SDK, credential cache or filesystem.
|
||||
** Shared HTTP seam; no provider SDK, credential cache or filesystem.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface EmailHttp
|
||||
public interface EmailHttp
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Exchange one request without retries.
|
||||
|
||||
@@ -81,13 +81,17 @@ final class EmailHttpSupport
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
envelope.getHeaders().forEach((name, value) ->
|
||||
{
|
||||
if(!name.equalsIgnoreCase("Bcc") && !name.equalsIgnoreCase("Auto-Submitted") && !name.equalsIgnoreCase("X-QQQ-Email-Id"))
|
||||
if(!name.equalsIgnoreCase("Bcc") && !name.equalsIgnoreCase("Auto-Submitted") && !name.equalsIgnoreCase("X-QQQ-Email-Id") && !name.equalsIgnoreCase("Message-ID"))
|
||||
{
|
||||
headers.put(name, value);
|
||||
}
|
||||
});
|
||||
headers.put("Auto-Submitted", "auto-generated");
|
||||
headers.put("X-QQQ-Email-Id", envelope.getMessageId());
|
||||
if(envelope.getRfcMessageId() != null)
|
||||
{
|
||||
headers.put("Message-ID", envelope.getRfcMessageId());
|
||||
}
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,26 @@ final class EmailMime
|
||||
noLineBreaks(envelope.getFrom());
|
||||
noLineBreaks(envelope.getReplyTo());
|
||||
noLineBreaks(envelope.getMessageId());
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
noLineBreaks(envelope.getRfcMessageId());
|
||||
String rfcMessageId = envelope.getRfcMessageId();
|
||||
MimeMessage message = new MimeMessage(session)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** JavaMail may save again when sending; keep the persisted identity each time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void updateMessageID() throws MessagingException
|
||||
{
|
||||
if(rfcMessageId == null)
|
||||
{
|
||||
super.updateMessageID();
|
||||
}
|
||||
else
|
||||
{
|
||||
setHeader("Message-ID", rfcMessageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
message.setFrom(new InternetAddress(envelope.getFrom(), true));
|
||||
recipients(message, Message.RecipientType.TO, envelope.getTo());
|
||||
recipients(message, Message.RecipientType.CC, envelope.getCc());
|
||||
|
||||
@@ -96,6 +96,10 @@ public class MailgunEmailTransport implements EmailTransport
|
||||
field(body, boundary, "o:tracking-opens", settings.enabled("trackOpens") ? "yes" : "no");
|
||||
field(body, boundary, "o:tracking-clicks", settings.enabled("trackClicks") ? "yes" : "no");
|
||||
field(body, boundary, "v:qqq_email_id", envelope.getMessageId());
|
||||
if(envelope.getRfcMessageId() != null)
|
||||
{
|
||||
field(body, boundary, "h:Message-Id", envelope.getRfcMessageId());
|
||||
}
|
||||
body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"message\"; filename=\"message.eml\"\r\n"
|
||||
+ "Content-Type: message/rfc822\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
EmailMime.build(Session.getInstance(new Properties()), envelope).writeTo(body, new String[] {"Bcc"});
|
||||
|
||||
@@ -50,6 +50,9 @@ import software.amazon.awssdk.services.sesv2.model.SesV2Exception;
|
||||
|
||||
/*******************************************************************************
|
||||
** SES v2 raw MIME transport; credentials are resolved only for an attempt.
|
||||
** The raw request carries the stored Message-ID, but SES replaces it downstream.
|
||||
** Receiver deduplication by that header is unavailable with SES; X-QQQ-Email-Id
|
||||
** remains the application correlation header. See docs/DURABLE-JOBS.md.
|
||||
*******************************************************************************/
|
||||
public class SesEmailTransport implements EmailTransport
|
||||
{
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.webhook;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.tracking.EmailTrackingStore;
|
||||
import com.kingsrook.qbits.email.tracking.EmailUnsubscribe;
|
||||
import com.kingsrook.qbits.email.tracking.MailgunVerifier;
|
||||
import com.kingsrook.qbits.email.tracking.PostmarkVerifier;
|
||||
import com.kingsrook.qbits.email.tracking.SendGridVerifier;
|
||||
import com.kingsrook.qbits.email.tracking.SnsVerifier;
|
||||
import com.kingsrook.qbits.email.tracking.WebhookVerification;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated provider ingress and signed public opt-out outside the UI API.
|
||||
** No bodies, tokens, addresses or exception diagnostics are logged here.
|
||||
*******************************************************************************/
|
||||
public class EmailWebhookRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final int MAX_BODY = 1024 * 1024;
|
||||
private QInstance instance;
|
||||
private final SnsVerifier sns;
|
||||
|
||||
/*******************************************************************************
|
||||
** Middleware constructs the default route provider.
|
||||
*******************************************************************************/
|
||||
public EmailWebhookRouteProvider()
|
||||
{
|
||||
this(new SnsVerifier());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject the existing HTTP seam through the SNS verifier for local tests.
|
||||
*******************************************************************************/
|
||||
public EmailWebhookRouteProvider(SnsVerifier sns)
|
||||
{
|
||||
this.sns = sns;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent registration even when a producer is run repeatedly.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
|
||||
if(CollectionUtils.nonNullList(metadata.getAdditionalRouteProviderReferences()).stream()
|
||||
.noneMatch(reference -> reference != null && EmailWebhookRouteProvider.class.getName().equals(reference.getName())))
|
||||
{
|
||||
metadata.withAdditionalRouteProviderReference(new QCodeReference(EmailWebhookRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplied by the host middleware.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Specific unsubscribe routes precede the provider wildcard route.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
String base = EmailRuntime.config(instance).getWebhookBasePath();
|
||||
config.routes.get(base + "/unsubscribe/{token}", this::unsubscribe);
|
||||
config.routes.post(base + "/unsubscribe/{token}", this::unsubscribe);
|
||||
config.routes.post(base + "/{kind}/{providerName}", this::receive);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound streaming input even when Content-Length is absent or dishonest.
|
||||
*******************************************************************************/
|
||||
private static byte[] body(Context context) throws java.io.IOException
|
||||
{
|
||||
if(context.req().getContentLengthLong() > MAX_BODY)
|
||||
{
|
||||
respond(context, 413, "error", "Payload too large");
|
||||
return (null);
|
||||
}
|
||||
byte[] bytes = context.req().getInputStream().readNBytes(MAX_BODY + 1);
|
||||
if(bytes.length > MAX_BODY)
|
||||
{
|
||||
respond(context, 413, "error", "Payload too large");
|
||||
return (null);
|
||||
}
|
||||
return (bytes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate before storing. Missing configuration and DB outages are retryable.
|
||||
*******************************************************************************/
|
||||
private void receive(Context context)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
|
||||
byte[] bytes = body(context);
|
||||
if(bytes == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String kind = context.pathParam("kind").toUpperCase(Locale.ROOT);
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, EmailStore.equal("name", context.pathParam("providerName")));
|
||||
if(providers.size() != 1 || !Boolean.TRUE.equals(providers.getFirst().getValueBoolean("enabled"))
|
||||
|| !kind.equals(providers.getFirst().getValueString("kind")) || !List.of("SES", "POSTMARK", "SENDGRID", "MAILGUN").contains(kind))
|
||||
{
|
||||
respond(context, 404, "error", "Provider not found");
|
||||
return;
|
||||
}
|
||||
QRecord provider = providers.getFirst();
|
||||
String secret = "SES".equals(kind) ? provider.getValueString("snsTopicArn") : WebhookVerification.secret(provider);
|
||||
if(secret == null || secret.isBlank())
|
||||
{
|
||||
respond(context, 503, "error", "Webhook verification unavailable");
|
||||
return;
|
||||
}
|
||||
String payload = new String(bytes, StandardCharsets.UTF_8);
|
||||
JSONObject json = "SENDGRID".equals(kind) ? new JSONObject() : new JSONObject(payload);
|
||||
boolean verified = switch(kind)
|
||||
{
|
||||
case "SES" -> sns.verify(json, secret, EmailRuntime.clock().instant());
|
||||
case "POSTMARK" -> new PostmarkVerifier().verify(context.header("Authorization"), secret);
|
||||
case "SENDGRID" -> new SendGridVerifier().verify(bytes, context.header("X-Twilio-Email-Event-Webhook-Signature"),
|
||||
context.header("X-Twilio-Email-Event-Webhook-Timestamp"), secret, EmailRuntime.clock().instant());
|
||||
case "MAILGUN" -> new MailgunVerifier().verify(json, secret, EmailRuntime.clock().instant());
|
||||
default -> false;
|
||||
};
|
||||
if(!verified)
|
||||
{
|
||||
respond(context, 401, "error", "Webhook verification failed");
|
||||
return;
|
||||
}
|
||||
String id = WebhookVerification.hash(bytes);
|
||||
String nonce = null;
|
||||
String note = null;
|
||||
if("SES".equals(kind))
|
||||
{
|
||||
id = json.getString("MessageId");
|
||||
if(!"Notification".equals(json.getString("Type")))
|
||||
{
|
||||
if(!SnsVerifier.allowedUrl(json.getString("SubscribeURL"), false))
|
||||
{
|
||||
respond(context, 401, "error", "Webhook verification failed");
|
||||
return;
|
||||
}
|
||||
if("SubscriptionConfirmation".equals(json.getString("Type")))
|
||||
{
|
||||
sns.confirm(json);
|
||||
}
|
||||
note = "SNS subscription control message verified";
|
||||
}
|
||||
}
|
||||
else if("MAILGUN".equals(kind))
|
||||
{
|
||||
id = json.getJSONObject("event-data").getString("id");
|
||||
nonce = json.getJSONObject("signature").getString("token");
|
||||
}
|
||||
else if("POSTMARK".equals(kind) && json.has("ID"))
|
||||
{
|
||||
id = json.getString("RecordType") + ":" + json.get("ID");
|
||||
}
|
||||
else if("SENDGRID".equals(kind))
|
||||
{
|
||||
new JSONArray(payload);
|
||||
}
|
||||
if(id.isBlank() || id.length() > 500)
|
||||
{
|
||||
respond(context, 401, "error", "Webhook verification failed");
|
||||
return;
|
||||
}
|
||||
EmailTrackingStore.Receipt outcome = EmailTrackingStore.receive(provider, id, nonce, bytes, note);
|
||||
respond(context, outcome == EmailTrackingStore.Receipt.REPLAY ? 401 : 200,
|
||||
outcome == EmailTrackingStore.Receipt.REPLAY ? "error" : outcome == EmailTrackingStore.Receipt.DUPLICATE ? "duplicate" : "received", true);
|
||||
}
|
||||
catch(org.json.JSONException e)
|
||||
{
|
||||
respond(context, 401, "error", "Webhook verification failed");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
if(e instanceof InterruptedException)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
respond(context, 503, "error", "Email inbox unavailable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** GET is read-only. Only an explicit one-click or page form POST opts out.
|
||||
*******************************************************************************/
|
||||
private void unsubscribe(Context context)
|
||||
{
|
||||
CapturedContext previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer")
|
||||
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'");
|
||||
byte[] bytes = body(context);
|
||||
if(bytes == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String token = context.pathParam("token");
|
||||
EmailUnsubscribe service = new EmailUnsubscribe();
|
||||
QRecord message = service.resolve(token);
|
||||
if(message == null)
|
||||
{
|
||||
context.status(404).contentType("text/html; charset=utf-8").result("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>Link not found</title><p>This link is unavailable.</p></html>");
|
||||
return;
|
||||
}
|
||||
boolean post = "POST".equals(context.req().getMethod());
|
||||
boolean oneClick = false;
|
||||
if(post)
|
||||
{
|
||||
String form = new String(bytes, StandardCharsets.UTF_8);
|
||||
oneClick = "List-Unsubscribe=One-Click".equals(form);
|
||||
if(!oneClick && !"confirm=unsubscribe".equals(form))
|
||||
{
|
||||
respond(context, 400, "error", "Unsubscribe confirmation required");
|
||||
return;
|
||||
}
|
||||
service.unsubscribe(token);
|
||||
}
|
||||
context.status(200).contentType(oneClick ? "text/plain" : "text/html; charset=utf-8").result(oneClick ? "" : service.page(message, post));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
respond(context, 503, "error", "Unsubscribe unavailable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed JSON responses contain no request or credential data.
|
||||
*******************************************************************************/
|
||||
private static void respond(Context context, int status, String key, Object value)
|
||||
{
|
||||
context.status(status).contentType("application/json").result(new JSONObject().put(key, value).toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-email starter defaults: one neutral, high-contrast brand to copy or select,
|
||||
-- so the Brands table is not empty and its colors are a safe starting point.
|
||||
-- Include it from the host's master changelog AFTER the changesets that create the
|
||||
-- email_* tables (docs/ddl/email-postgres.sql):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
--
|
||||
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
|
||||
-- Rules for this file:
|
||||
-- * insert-only, and only when the host has no brand at all, so a host that already
|
||||
-- configured its branding is never touched;
|
||||
-- * it never changes how mail looks on deploy: the brand is NOT the default brand
|
||||
-- (is_default = FALSE), so it applies only when someone selects it (a template's brand,
|
||||
-- a request's brand name) or ticks Default Brand on it;
|
||||
-- * no secrets, and portable SQL (PostgreSQL and H2);
|
||||
-- * every changeset has a precondition (sql-check only; table-exists is not portable across
|
||||
-- Liquibase versions), so a host without the table skips it (MARK_RAN).
|
||||
-- Providers, sender addresses, suppression and every runtime table are NOT seeded: a provider
|
||||
-- is the host's own connection (a server and the NAMES of its secret variables). The built-in
|
||||
-- secure-message template is created by the qbit itself on first use and when the Email home opens.
|
||||
-- EmailDefaultsChangelogTest runs this file through Liquibase on a fresh schema.
|
||||
|
||||
-- changeset qbit-email:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand
|
||||
-- comment: A neutral brand (white background, near-black text, blue buttons; light and dark schemes, every pair at least 4.5:1 contrast). Not the default brand, so nothing changes until it is selected.
|
||||
INSERT INTO email_brand (name, label, primary_color, text_color, background_color, button_text_color, dark_primary_color, dark_text_color, dark_background_color, dark_button_text_color, is_default, create_date, modify_date)
|
||||
SELECT 'neutral', 'Neutral', '#2459a6', '#171717', '#ffffff', '#ffffff', '#8ab4f8', '#f5f5f5', '#121212', '#000000', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM email_brand);
|
||||
-- rollback empty
|
||||
@@ -0,0 +1,710 @@
|
||||
bundle: qbit-email
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:email;slot:help
|
||||
summary: 'Manage outgoing email, templates, delivery results and addresses that must not receive messages.'
|
||||
content: |
|
||||
Use **Messages** to follow outgoing mail from queuing through delivery attempts and provider events. Open **Templates** and their versions to prepare reusable content, and **Brands** to control its appearance.
|
||||
|
||||
Configure delivery services and sender addresses under **Providers**. **Suppressions** records addresses that should not receive some or all mail. Sending uses the outbox, so a queued message is not yet proof of delivery. Your email permissions determine which settings and actions you can use.
|
||||
- key: table:email_provider;slot:help
|
||||
summary: 'A delivery service and its sender, connection, tracking and rate-limit settings.'
|
||||
content: |
|
||||
**How rows get created:** Add your delivery connection with **New**, then configure an enabled default before sending. Without a provider, messages cannot be sent.
|
||||
|
||||
Configure a provider before sending mail. Choose the delivery service, connection settings and sender identity, then use **Send Test Email** to check it. Credential settings hold environment variable names; the server resolves their values when needed.
|
||||
|
||||
When providers are enabled, exactly one must be the enabled default. Providers with message history cannot be deleted; disable them instead. Open **Sender Addresses** to manage additional senders, **Messages** to inspect outgoing mail and **Webhook Inbox** to inspect delivery notifications.
|
||||
- key: table:email_provider;field:name
|
||||
summary: 'A unique name used to select this delivery provider.'
|
||||
- key: table:email_provider;field:label
|
||||
summary: 'The display name for this provider.'
|
||||
- key: table:email_provider;field:kind
|
||||
summary: 'The delivery service or protocol used to send mail.'
|
||||
content: |
|
||||
Choose the transport used by your delivery service. The available choices are {{values:email_provider_kind}}. Connection and credential requirements depend on the chosen kind, so check the provider setup before testing. Gmail API sending is not implemented in this version; use Gmail SMTP when appropriate. Use **Send Test Email** to check the configured provider.
|
||||
- key: table:email_provider;field:enabled
|
||||
summary: 'Whether this provider is available for sending mail.'
|
||||
- key: table:email_provider;field:isDefault
|
||||
summary: 'Whether this is the enabled provider used when no provider is selected.'
|
||||
content: |
|
||||
The default provider is selected when a send does not name a provider. When any providers are enabled, exactly one must be the enabled default. Coordinate changes to the old and new defaults together. This choice selects the delivery service; the default under **Sender Addresses** separately controls the preferred sender within that service.
|
||||
- key: table:email_provider;field:fromAddress
|
||||
summary: 'The fallback sender address when no sender-address row takes precedence.'
|
||||
content: |
|
||||
These values provide the provider’s fallback sending identity. An enabled default under **Sender Addresses** takes precedence. Once sender-address rows exist, an explicitly supplied sender must match an allowed address for this provider. Check both the fallback and the sender rows when a send is refused or a test uses an unexpected identity.
|
||||
- key: table:email_provider;field:fromName
|
||||
summary: 'The display name accompanying the fallback sender address.'
|
||||
content: |
|
||||
These values provide the provider’s fallback sending identity. An enabled default under **Sender Addresses** takes precedence. Once sender-address rows exist, an explicitly supplied sender must match an allowed address for this provider. Check both the fallback and the sender rows when a send is refused or a test uses an unexpected identity.
|
||||
- key: table:email_provider;field:replyTo
|
||||
summary: 'The address recipients should use when replying.'
|
||||
- key: table:email_provider;field:sendingDomain
|
||||
summary: 'The sending domain used by the provider and its allowed sender addresses.'
|
||||
content: |
|
||||
The sending domain is used by provider-specific delivery configuration and constrains **Sender Addresses**. When a domain is configured, every sender-address row must belong to it. A change that would leave existing sender rows in another domain is refused. Review the provider and its sender list together before changing this setting, then test the resulting delivery configuration.
|
||||
- key: table:email_provider;field:host
|
||||
summary: 'The mail server hostname for an SMTP connection.'
|
||||
- key: table:email_provider;field:port
|
||||
summary: 'The mail server port for an SMTP connection.'
|
||||
- key: table:email_provider;field:security
|
||||
summary: 'The transport security setting for the SMTP connection.'
|
||||
content: |
|
||||
Choose the connection protection required by your SMTP server. The available settings are {{values:email_security}}, with metadata default {{default:email_provider.security}}. STARTTLS requires upgrading the connection, while TLS starts with an encrypted connection. Match the host and port to the server’s configuration. Check the result with **Send Test Email** before relying on outgoing mail.
|
||||
- key: table:email_provider;field:region
|
||||
summary: 'The provider region used for delivery.'
|
||||
- key: table:email_provider;field:usernameEnvName
|
||||
summary: 'The environment variable name containing the provider’s login name or access key.'
|
||||
content: |
|
||||
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
|
||||
- key: table:email_provider;field:secretEnvName
|
||||
summary: 'The environment variable name containing the provider’s password or API credential.'
|
||||
content: |
|
||||
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
|
||||
- key: table:email_provider;field:webhookSecretEnvName
|
||||
summary: 'The environment variable name containing the notification verification credential.'
|
||||
content: |
|
||||
Enter the **name** of an environment variable available to the server, never the credential value. The name must be allowed for the provider configuration. The service resolves the value when it needs to authenticate or verify a notification. If setup fails, check both the allowed reference and whether that variable is set on the running server.
|
||||
- key: table:email_provider;field:snsTopicArn
|
||||
summary: 'The Amazon notification topic expected for SES delivery notifications.'
|
||||
- key: table:email_provider;field:configurationSet
|
||||
summary: 'The SES configuration set attached to outgoing mail.'
|
||||
- key: table:email_provider;field:messageStream
|
||||
summary: 'The Postmark message stream used for outgoing mail.'
|
||||
- key: table:email_provider;field:maxPerMinute
|
||||
summary: 'The provider’s configured sending limit per minute.'
|
||||
- key: table:email_provider;field:maxPerDay
|
||||
summary: 'The provider’s configured sending limit per day.'
|
||||
- key: table:email_provider;field:trackOpens
|
||||
summary: 'Whether open tracking is requested from a provider that supports it.'
|
||||
content: |
|
||||
Tracking requests engagement information from a provider that supports it. These settings are separate from basic sending and delivery notifications. Review whether tracking is appropriate for your messages before enabling it. Reported engagement appears under **Events** on a message; absence of a tracking event does not prove the recipient did not read or use the message.
|
||||
- key: table:email_provider;field:trackClicks
|
||||
summary: 'Whether link tracking is requested from a provider that supports it.'
|
||||
content: |
|
||||
Tracking requests engagement information from a provider that supports it. These settings are separate from basic sending and delivery notifications. Review whether tracking is appropriate for your messages before enabling it. Reported engagement appears under **Events** on a message; absence of a tracking event does not prove the recipient did not read or use the message.
|
||||
- key: table:email_provider;field:phiAllowed
|
||||
summary: 'Whether this provider is approved to receive protected health information.'
|
||||
content: |
|
||||
This setting records whether the provider may receive protected health information. It is only one part of the send checks: template-version approval and variable classification also matter. Review your approved provider arrangement before enabling it. If a protected message is refused, inspect the **Template**, its approved version and the chosen provider rather than treating this flag as a universal override.
|
||||
- key: table:email_provider;field:healthStatus
|
||||
summary: 'The provider’s current sending health state.'
|
||||
content: |
|
||||
The recorded health states are {{values:email_health_status}}. Use this together with the most recent success, failure time and failure message to investigate provider problems. Account and credential failures can pause sending through a provider. Check the configuration and use **Send Test Email** after correcting the cause, then inspect the resulting message and attempts.
|
||||
- key: table:email_provider;field:lastSuccessDate
|
||||
summary: 'When sending through this provider last succeeded.'
|
||||
- key: table:email_provider;field:lastFailureDate
|
||||
summary: 'When sending through this provider last failed.'
|
||||
- key: table:email_provider;field:lastFailureMessage
|
||||
summary: 'The most recent sending failure recorded for this provider.'
|
||||
- key: table:email_provider_sender_address;slot:help
|
||||
summary: 'An address you can send from through a particular email provider.'
|
||||
content: |
|
||||
**How rows get created:** Add an address from a provider's **Sender Addresses** list. Without sender-address rows, the provider's fallback sender is used.
|
||||
|
||||
Use **Sender Addresses** on a provider to manage its allowed sending identities. Each address must be unique within that provider, and an address must match the provider’s sending domain when one is configured.
|
||||
|
||||
At most one enabled address can be the default. That default takes precedence over the provider’s fallback sender. Once sender rows exist, an explicitly chosen sender must match an enabled row for the selected provider. The selected address is recorded on **Messages**.
|
||||
- key: table:email_provider_sender_address;field:providerId
|
||||
summary: 'The provider allowed to use this sender address.'
|
||||
- key: table:email_provider_sender_address;field:address
|
||||
summary: 'The email address used as the sender.'
|
||||
- key: table:email_provider_sender_address;field:displayName
|
||||
summary: 'The name recipients see beside the sender address.'
|
||||
- key: table:email_provider_sender_address;field:isDefault
|
||||
summary: 'Whether this enabled address is the provider’s preferred sender.'
|
||||
- key: table:email_provider_sender_address;field:enabled
|
||||
summary: 'Whether this address can be selected for sending.'
|
||||
- key: table:email_provider_sender_address;field:note
|
||||
summary: 'Your notes about this sender address.'
|
||||
- key: table:email_message;slot:help
|
||||
summary: 'An outgoing email with its saved content, delivery state and retry history.'
|
||||
content: |
|
||||
**How rows get created:** Your application queues messages through the email service. You do not enter them manually. An empty list means no messages are currently retained.
|
||||
|
||||
Use **Messages** to check whether mail is waiting, being attempted, sent or reported as delivered. A provider accepting a message does not by itself prove that the recipient received it. Open **Attempts** for individual sending attempts and **Events** for provider notifications.
|
||||
|
||||
Messages are written by the email service, rather than edited directly. Use **Cancel**, **Send Now** or **Resend** when the message’s state permits the action. Saved content reflects the original send and may later be cleared by retention.
|
||||
- key: table:email_message;field:status
|
||||
summary: 'The current delivery state of the outgoing message.'
|
||||
content: |
|
||||
The available states are {{values:email_message_status}}. Use the state to distinguish waiting or retrying mail from a provider handoff and later delivery events. A sent message is not necessarily delivered. Open **Attempts** for sending errors and **Events** for delivery, bounce or complaint reports before deciding whether a resend is appropriate.
|
||||
- key: table:email_message;field:toAddresses
|
||||
summary: 'The message’s primary recipients.'
|
||||
- key: table:email_message;field:ccAddresses
|
||||
summary: 'The recipients copied visibly on the message.'
|
||||
- key: table:email_message;field:bccAddresses
|
||||
summary: 'The recipients copied without appearing in the message’s recipient headers.'
|
||||
- key: table:email_message;field:fromAddress
|
||||
summary: 'The sender address saved for this message.'
|
||||
- key: table:email_message;field:fromAddressId
|
||||
summary: 'The provider sender-address record selected for this message.'
|
||||
- key: table:email_message;field:replyTo
|
||||
summary: 'The reply address saved for this message.'
|
||||
- key: table:email_message;field:subject
|
||||
summary: 'The subject saved when the message was prepared.'
|
||||
- key: table:email_message;field:templateId
|
||||
summary: 'The template used to prepare this message.'
|
||||
- key: table:email_message;field:templateVersionId
|
||||
summary: 'The exact template version used to prepare this message.'
|
||||
- key: table:email_message;field:brandId
|
||||
summary: 'The brand used when rendering this message.'
|
||||
- key: table:email_message;field:category
|
||||
summary: 'The message category used for delivery and suppression rules.'
|
||||
content: |
|
||||
The categories are {{values:email_category}}. Category affects how suppression and unsubscribe rules apply, so check it when a recipient is omitted from a message. It also matters for retention: notification and marketing records remain to keep unsubscribe links working even after old content is cleared. Review the related **Suppression** and **Template** when investigating.
|
||||
- key: table:email_message;field:locale
|
||||
summary: 'The language or locale requested for the message.'
|
||||
- key: table:email_message;field:variablesSnapshot
|
||||
summary: 'The retained template variables, with sensitive values redacted.'
|
||||
content: |
|
||||
Use the saved variables to investigate what was queued. Sensitive values are replaced by non-recoverable tokens or redaction markers, so this snapshot is not a copy of every original input. Later template changes do not regenerate it. Retention can clear saved variables while preserving the message record and its identifiers.
|
||||
- key: table:email_message;field:htmlBody
|
||||
summary: 'The rendered HTML content saved for this message.'
|
||||
content: |
|
||||
The message keeps the content prepared for this particular send. Inspect it when you need to understand what was queued, rather than assuming the current template still matches the original. Later template changes do not regenerate this saved content. Retention can clear bodies and variable values while preserving the message record and its identifiers.
|
||||
- key: table:email_message;field:textBody
|
||||
summary: 'The plain-text content saved for this message.'
|
||||
content: |
|
||||
The message keeps the content prepared for this particular send. Inspect it when you need to understand what was queued, rather than assuming the current template still matches the original. Later template changes do not regenerate this saved content. Retention can clear bodies and variable values while preserving the message record and its identifiers.
|
||||
- key: table:email_message;field:phiApproved
|
||||
summary: 'Whether the message carries approval for protected health information.'
|
||||
- key: table:email_message;field:unsubscribeUrl
|
||||
summary: 'The unsubscribe link prepared for this message.'
|
||||
- key: table:email_message;field:bodySha256
|
||||
summary: 'A checksum identifying the saved message content.'
|
||||
- key: table:email_message;field:idempotencyKey
|
||||
summary: 'A caller-supplied reference used to prevent duplicate message creation.'
|
||||
content: |
|
||||
A sending caller can provide a stable reference so repeating the same request does not create duplicate mail while that record remains stored. Use it to trace retried application requests alongside the recorded message identifier. The reference is not the provider’s message identifier and does not prove delivery. Retention settings can affect how long message records remain available.
|
||||
- key: table:email_message;field:providerId
|
||||
summary: 'The delivery provider chosen for this message.'
|
||||
- key: table:email_message;field:providerMessageId
|
||||
summary: 'The identifier returned by the delivery provider.'
|
||||
- key: table:email_message;field:attemptCount
|
||||
summary: 'The number of sending attempts recorded for this message.'
|
||||
- key: table:email_message;field:nextAttemptDate
|
||||
summary: 'When this message next becomes eligible for a sending attempt.'
|
||||
content: |
|
||||
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
|
||||
- key: table:email_message;field:leaseExpiresDate
|
||||
summary: 'When the dispatcher’s current claim on this message expires.'
|
||||
content: |
|
||||
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
|
||||
- key: table:email_message;field:lastError
|
||||
summary: 'The latest error recorded while sending this message.'
|
||||
- key: table:email_message;field:finishedDate
|
||||
summary: 'When the message reached its recorded terminal state.'
|
||||
- key: table:email_message;field:sentDate
|
||||
summary: 'When the message was successfully handed to its provider.'
|
||||
- key: table:email_message;field:deliveredDate
|
||||
summary: 'When delivery was recorded from the provider’s notification.'
|
||||
- key: table:email_message;field:bouncedDate
|
||||
summary: 'When a bounce was recorded for this message.'
|
||||
- key: table:email_message;field:complainedDate
|
||||
summary: 'When a recipient complaint was recorded for this message.'
|
||||
- key: table:email_message;field:deadLetterDate
|
||||
summary: 'When the message was moved out of automatic retrying.'
|
||||
- key: table:email_message;field:linkedTableName
|
||||
summary: 'The type of application record associated with this message.'
|
||||
- key: table:email_message;field:linkedRecordId
|
||||
summary: 'The application record associated with this message.'
|
||||
- key: table:email_message;field:senderQbit
|
||||
summary: 'The application component that requested this message.'
|
||||
- key: table:email_message;field:senderProcess
|
||||
summary: 'The action that requested this message.'
|
||||
- key: table:email_message;field:suppressedAddresses
|
||||
summary: 'Recipients omitted because a suppression applied.'
|
||||
- key: table:email_message;field:sendAfter
|
||||
summary: 'The earliest requested time for sending this message.'
|
||||
content: |
|
||||
These times explain why a queued message is not currently being sent. **{{label:email_message.sendAfter}}** records a requested delay, **{{label:email_message.nextAttemptDate}}** records retry eligibility, and **{{label:email_message.leaseExpiresDate}}** marks the dispatcher’s claim. Use **Attempts** and the current status together when investigating. Avoid treating an expired claim as evidence that a recipient received or did not receive the email.
|
||||
- key: table:email_message_attempt;slot:help
|
||||
summary: 'One attempt to hand an outgoing message to its delivery provider.'
|
||||
content: |
|
||||
**How rows get created:** The dispatcher records each attempt to send a message. An empty list means no attempts are currently retained.
|
||||
|
||||
Open **Attempts** from a message when you need to understand a retry or sending failure. Each row records the provider, attempt number, timing and outcome of one attempt. The worker and lease information help distinguish active work from an abandoned claim.
|
||||
|
||||
These records are written by the dispatcher and are not editable. Compare the error and outcome with the parent **Message**, which holds the overall delivery state and next retry time.
|
||||
- key: table:email_message_attempt;field:messageId
|
||||
summary: 'The outgoing message this attempt belongs to.'
|
||||
- key: table:email_message_attempt;field:attemptNumber
|
||||
summary: 'The sequence number of this sending attempt.'
|
||||
- key: table:email_message_attempt;field:outcome
|
||||
summary: 'The result of this individual sending attempt.'
|
||||
content: |
|
||||
The possible outcomes are {{values:email_attempt_outcome}}. An attempt records the result of one handoff to a provider, while **Message** holds the overall state and retry timing. Review the error and the other attempts to distinguish a temporary problem from a repeated provider failure. Even a successful handoff can later receive a bounce event.
|
||||
- key: table:email_message_attempt;field:providerId
|
||||
summary: 'The provider used for this attempt.'
|
||||
- key: table:email_message_attempt;field:claimedBy
|
||||
summary: 'The dispatcher worker that claimed this attempt.'
|
||||
- key: table:email_message_attempt;field:startDate
|
||||
summary: 'When this sending attempt started.'
|
||||
- key: table:email_message_attempt;field:endDate
|
||||
summary: 'When this sending attempt ended.'
|
||||
- key: table:email_message_attempt;field:leaseExpiresDate
|
||||
summary: 'When this worker’s claim on the attempt expires.'
|
||||
- key: table:email_message_attempt;field:durationMillis
|
||||
summary: 'The elapsed time for this attempt in milliseconds.'
|
||||
- key: table:email_message_attempt;field:error
|
||||
summary: 'The error recorded for this attempt.'
|
||||
- key: table:email_template;slot:help
|
||||
summary: 'Reusable email content with versions, branding and approval information.'
|
||||
content: |
|
||||
**How rows get created:** Add a template with **New**, or use templates registered by your application. The built-in secure-message template is created on first use or when you open the Email home.
|
||||
|
||||
Use **Templates** for messages you send repeatedly with different recipient data. A template identifies its format, category and brand; its **Versions** hold the actual subject and content. **New Version** creates a draft you can edit without changing the currently active version.
|
||||
|
||||
Preview the draft with sample variables before choosing **Activate Version**. Approval for protected health information is separate from activation and is tied to a particular version. Template editing can require an additional permission.
|
||||
- key: table:email_template;field:name
|
||||
summary: 'A unique name used to select this template.'
|
||||
- key: table:email_template;field:label
|
||||
summary: 'The display name of the template.'
|
||||
- key: table:email_template;field:category
|
||||
summary: 'The category applied to messages prepared from this template.'
|
||||
- key: table:email_template;field:kind
|
||||
summary: 'The source format used to write this template’s versions.'
|
||||
- key: table:email_template;field:brandId
|
||||
summary: 'The brand used to style this template.'
|
||||
- key: table:email_template;field:activeVersionId
|
||||
summary: 'The version currently selected for normal sends.'
|
||||
- key: table:email_template;field:approvedVersionId
|
||||
summary: 'The specific version approved for protected health information.'
|
||||
content: |
|
||||
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
|
||||
- key: table:email_template;field:phiApproved
|
||||
summary: 'Whether this template has recorded approval for protected health information.'
|
||||
content: |
|
||||
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
|
||||
- key: table:email_template;field:phiApprovedBy
|
||||
summary: 'Who approved this template for protected health information.'
|
||||
content: |
|
||||
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
|
||||
- key: table:email_template;field:phiApprovedDate
|
||||
summary: 'When approval for protected health information was recorded.'
|
||||
content: |
|
||||
Protected-health approval is recorded against a particular template version. Review the version’s content and variable declarations before using **Approve For PHI**. Creating or activating another version does not mean that every version is approved. Use the approved-version reference to check which content was reviewed, and check the delivery provider’s approval separately before sending sensitive data.
|
||||
- key: table:email_template;field:editPermissionName
|
||||
summary: 'An additional permission required to edit this template.'
|
||||
- key: table:email_template;field:ownerQbit
|
||||
summary: 'The application component responsible for this template.'
|
||||
- key: table:email_template;field:description
|
||||
summary: 'Your description of when to use this template.'
|
||||
- key: table:email_template_version;slot:help
|
||||
summary: 'A draft or published revision of a template’s subject, content and variables.'
|
||||
content: |
|
||||
**How rows get created:** Use **New Version** on a template to create a draft. Application-registered templates can also create their initial version.
|
||||
|
||||
Open a template’s **Versions** to edit a draft or inspect the content used by an earlier message. The **Preview** shows rendered content using sample variables. Provide the source in the format chosen on the parent **Template**, with optional plain-text content and a variable schema.
|
||||
|
||||
Only unapproved drafts can be edited. Use **Activate Version** to make reviewed content available for normal sends. Existing messages retain their saved content; activating a version does not rewrite them.
|
||||
- key: table:email_template_version;field:templateId
|
||||
summary: 'The template this version belongs to.'
|
||||
- key: table:email_template_version;field:versionNumber
|
||||
summary: 'The revision number within the template.'
|
||||
- key: table:email_template_version;field:status
|
||||
summary: 'Whether this version is a draft, active or retired.'
|
||||
- key: table:email_template_version;field:locale
|
||||
summary: 'The language or locale of this version.'
|
||||
- key: table:email_template_version;field:subject
|
||||
summary: 'The subject template rendered with message variables.'
|
||||
- key: table:email_template_version;field:preheader
|
||||
summary: 'The short preview text shown by supporting email clients.'
|
||||
- key: table:email_template_version;field:bodySource
|
||||
summary: 'The editable source for the message’s main content.'
|
||||
content: |
|
||||
Write the main content in the format chosen on the parent **Template**. Edit a draft, then use **Preview** with sample variables to inspect the result. Generated HTML is recorded separately in **{{label:email_template_version.compiledHtml}}**. Activate the reviewed version when it is ready for normal sending; editing source does not change messages that have already been prepared.
|
||||
- key: table:email_template_version;field:textSource
|
||||
summary: 'The editable source for the plain-text alternative.'
|
||||
- key: table:email_template_version;field:compiledHtml
|
||||
summary: 'The generated HTML produced from the version’s source.'
|
||||
- key: table:email_template_version;field:variablesSchema
|
||||
summary: 'The declared variables and their classifications for this version.'
|
||||
content: |
|
||||
Declare the variables expected by the version and provide representative example values for **Preview**. The schema also carries classifications used by protected-data checks. Use sample data that is appropriate for previewing and review the rendered subject and body before activation. The sample values are for checking the template; actual sends supply their own values and save a message snapshot.
|
||||
- key: table:email_template_version;field:sampleVariables
|
||||
summary: 'Example variable values used to preview this version.'
|
||||
content: |
|
||||
Declare the variables expected by the version and provide representative example values for **Preview**. The schema also carries classifications used by protected-data checks. Use sample data that is appropriate for previewing and review the rendered subject and body before activation. The sample values are for checking the template; actual sends supply their own values and save a message snapshot.
|
||||
- key: table:email_template_version;field:createdBy
|
||||
summary: 'Who created this template version.'
|
||||
- key: table:email_template_version;field:activatedDate
|
||||
summary: 'When this version was activated.'
|
||||
- key: table:email_brand;slot:help
|
||||
summary: 'Shared logos, colors and footer information for rendered email templates.'
|
||||
content: |
|
||||
**How rows get created:** Add a brand with **New**. If your host installs the optional defaults changelog, you can copy its neutral starter brand and customize it.
|
||||
|
||||
Use **Brands** to give related templates a consistent appearance. Set the logos, light and dark colors, site address and footer information that your messages need. Associate the brand with a **Template** to use it when rendering email.
|
||||
|
||||
Check the template **Preview** after changing branding, including the dark appearance and text contrast. Branding supplies presentation and footer data; it does not configure the email provider or verify a sender address.
|
||||
- key: table:email_brand;field:name
|
||||
summary: 'A unique name used to select this brand.'
|
||||
- key: table:email_brand;field:label
|
||||
summary: 'The display name of this brand.'
|
||||
- key: table:email_brand;field:logoUrl
|
||||
summary: 'The logo image address used in the standard appearance.'
|
||||
- key: table:email_brand;field:logoDarkUrl
|
||||
summary: 'The logo image address used in the dark appearance.'
|
||||
- key: table:email_brand;field:logoAlt
|
||||
summary: 'The text alternative describing the logo.'
|
||||
- key: table:email_brand;field:primaryColor
|
||||
summary: 'The main accent color used in branded email.'
|
||||
- key: table:email_brand;field:textColor
|
||||
summary: 'The text color used in the standard appearance.'
|
||||
- key: table:email_brand;field:backgroundColor
|
||||
summary: 'The background color used in the standard appearance.'
|
||||
- key: table:email_brand;field:darkBackgroundColor
|
||||
summary: 'The background color used in the dark appearance.'
|
||||
- key: table:email_brand;field:darkPrimaryColor
|
||||
summary: 'The accent color used in the dark appearance.'
|
||||
- key: table:email_brand;field:darkButtonTextColor
|
||||
summary: 'The button text color used in the dark appearance.'
|
||||
- key: table:email_brand;field:darkTextColor
|
||||
summary: 'The text color used in the dark appearance.'
|
||||
- key: table:email_brand;field:buttonTextColor
|
||||
summary: 'The button text color used in the standard appearance.'
|
||||
- key: table:email_brand;field:footerMarkdown
|
||||
summary: 'The shared email footer written in Markdown.'
|
||||
- key: table:email_brand;field:postalAddress
|
||||
summary: 'The postal address available to the branded footer.'
|
||||
- key: table:email_brand;field:socialLinks
|
||||
summary: 'The social links supplied to branded email rendering.'
|
||||
- key: table:email_brand;field:siteUrl
|
||||
summary: 'The website address associated with this brand.'
|
||||
- key: table:email_brand;field:isDefault
|
||||
summary: 'Whether this brand is marked as the default.'
|
||||
- key: table:email_brand;field:appName
|
||||
summary: 'The application name displayed in branded email.'
|
||||
- key: table:email_suppression;slot:help
|
||||
summary: 'An address blocked from some or all email because of a delivery or preference event.'
|
||||
content: |
|
||||
**How rows get created:** Provider bounces, complaints and unsubscribe requests can create restrictions automatically. You can also add a manual block with **New**.
|
||||
|
||||
Check **Suppressions** when a recipient is omitted from a send. Each entry identifies an address, the scope of the restriction and its reason, such as a bounce, complaint or unsubscribe. The source message and event help explain why it was added.
|
||||
|
||||
Review the reason before changing a suppression. Complaint entries require email administrator permission to change or remove. An expiry limits how long an entry applies; removing a restriction does not resend previously suppressed messages.
|
||||
- key: table:email_suppression;field:address
|
||||
summary: 'The email address affected by this suppression.'
|
||||
- key: table:email_suppression;field:scope
|
||||
summary: 'The categories of mail blocked for this address.'
|
||||
content: |
|
||||
Scope determines which categories of mail a suppression blocks. The configured choices are {{values:email_suppression_scope}}. Review the reason and source event before changing a restriction, especially for complaints and unsubscribe requests. A suppression can omit a recipient from a send without changing the template or provider. Removing it does not automatically resend previously omitted mail.
|
||||
- key: table:email_suppression;field:reason
|
||||
summary: 'Why this address was suppressed.'
|
||||
- key: table:email_suppression;field:sourceMessageId
|
||||
summary: 'The message that led to this suppression.'
|
||||
- key: table:email_suppression;field:sourceEventId
|
||||
summary: 'The provider event that led to this suppression.'
|
||||
- key: table:email_suppression;field:note
|
||||
summary: 'Notes explaining this suppression.'
|
||||
- key: table:email_suppression;field:createdDate
|
||||
summary: 'When the suppression was created.'
|
||||
- key: table:email_suppression;field:expiresDate
|
||||
summary: 'When this suppression stops applying, if an expiry was set.'
|
||||
- key: table:email_webhook_inbox;slot:help
|
||||
summary: 'A provider notification received for later processing into email delivery events.'
|
||||
content: |
|
||||
**How rows get created:** Verified provider callbacks create inbox rows for background processing. Configure your provider's notification destination to receive delivery updates.
|
||||
|
||||
Use **Webhook Inbox** on a provider to investigate delivery notifications. A row keeps the received payload, its external identifier, processing state and any retry error. Processing can create **Events** and update the related **Message** or **Suppression**.
|
||||
|
||||
These rows are service-written. If processing failed, investigate the error before using **Reprocess**. The inbox tracks notification processing separately from sending attempts, so a failed inbox row does not necessarily mean the original email failed to send.
|
||||
- key: table:email_webhook_inbox;field:providerId
|
||||
summary: 'The provider that sent this notification.'
|
||||
- key: table:email_webhook_inbox;field:kind
|
||||
summary: 'The provider notification format recorded for this payload.'
|
||||
- key: table:email_webhook_inbox;field:externalEventId
|
||||
summary: 'The provider’s event identifier used to recognize repeat notifications.'
|
||||
- key: table:email_webhook_inbox;field:nonce
|
||||
summary: 'A notification reference used to detect replayed requests.'
|
||||
content: |
|
||||
The notification reference helps the receiver recognize replayed requests. It is recorded alongside the provider and external event identifier so repeated notifications can be handled safely. Use the inbox status, payload checksum and error when investigating duplicate or failed notifications. This value identifies notification processing; it is not a sender credential or proof of email delivery.
|
||||
- key: table:email_webhook_inbox;field:status
|
||||
summary: 'The notification’s current processing state.'
|
||||
- key: table:email_webhook_inbox;field:attempts
|
||||
summary: 'The number of processing attempts recorded for this notification.'
|
||||
- key: table:email_webhook_inbox;field:nextAttemptDate
|
||||
summary: 'When this notification is next eligible for processing.'
|
||||
- key: table:email_webhook_inbox;field:lastError
|
||||
summary: 'The latest error encountered while processing the notification.'
|
||||
- key: table:email_webhook_inbox;field:payloadJson
|
||||
summary: 'The received notification content stored for processing.'
|
||||
- key: table:email_webhook_inbox;field:payloadSha256
|
||||
summary: 'A checksum of the received notification content.'
|
||||
- key: table:email_webhook_inbox;field:receivedDate
|
||||
summary: 'When the notification was received.'
|
||||
- key: table:email_webhook_inbox;field:processedDate
|
||||
summary: 'When processing of the notification completed.'
|
||||
- key: table:email_event;slot:help
|
||||
summary: 'A delivery, bounce, complaint or engagement event reported for an outgoing message.'
|
||||
content: |
|
||||
**How rows get created:** Notification processing records events reported by your provider. You do not enter them manually; an empty list can mean no callbacks have arrived.
|
||||
|
||||
Open **Events** from a message to see what its provider reported after sending. Events can record delivery, bounces, complaints and engagement, together with the recipient, occurrence time and available detail. Availability depends on the provider and tracking configuration.
|
||||
|
||||
Events are written by notification processing, not entered manually. Compare them with **Attempts** when diagnosing mail: attempts describe handing mail to a provider, while events describe what the provider reported afterward.
|
||||
- key: table:email_event;field:messageId
|
||||
summary: 'The outgoing message this event describes.'
|
||||
- key: table:email_event;field:providerId
|
||||
summary: 'The provider that reported this event.'
|
||||
- key: table:email_event;field:eventType
|
||||
summary: 'The kind of delivery or engagement event reported.'
|
||||
- key: table:email_event;field:bounceType
|
||||
summary: 'The provider’s classification of the bounce.'
|
||||
- key: table:email_event;field:recipient
|
||||
summary: 'The recipient affected by this event.'
|
||||
- key: table:email_event;field:occurredDate
|
||||
summary: 'When the event occurred according to the recorded notification.'
|
||||
- key: table:email_event;field:detail
|
||||
summary: 'Additional information recorded for the event.'
|
||||
- key: table:email_event;field:url
|
||||
summary: 'The link associated with this event, when reported.'
|
||||
- key: process:email_resend;slot:help
|
||||
summary: 'Queue another message using an eligible message’s saved content.'
|
||||
content: |
|
||||
Use **Resend** after reviewing a failed or returned message and correcting its underlying problem. The action creates another send through the outbox using the saved message information; it does not rewrite the original delivery history. Review the selected messages first. Suppression, provider and content checks still apply, so resending is not a guarantee that mail will be accepted or delivered.
|
||||
- key: process:email_cancel;slot:help
|
||||
summary: 'Cancel an eligible outgoing message before the dispatcher sends it.'
|
||||
content: |
|
||||
Use **Cancel** when a queued message should no longer be sent. Review the selected messages before continuing. The action checks their current state again and cancels only messages still eligible for cancellation. It cannot recall mail already handed to a provider. Check **Messages** and **Attempts** afterward if a selected message was skipped or had already been claimed.
|
||||
- key: process:email_send_now;slot:help
|
||||
summary: 'Attempt to send an eligible queued message without waiting for normal dispatch.'
|
||||
content: |
|
||||
Use **Send Now** when you want the dispatcher to attempt an eligible message immediately. Review the selected messages before continuing. The send still goes through the outbox dispatcher and its provider, suppression and delivery controls. Open **Attempts** afterward to inspect the result. A successful handoff to a provider is separate from a later delivery notification.
|
||||
- key: process:email_send_test;slot:help
|
||||
summary: 'Send a test email through the selected enabled provider.'
|
||||
content: |
|
||||
Use **Send Test Email** to check a provider’s configuration with a recipient you control. Enter **To address** and optionally select one of that provider’s sender addresses. The test is queued through the normal email service and then attempted immediately. Review the result and the resulting **Message** or **Attempts** if the provider refuses the send or delivery does not arrive.
|
||||
- key: process:email_send_templated;slot:help
|
||||
summary: 'Prepare and queue an email using a template and supplied variables.'
|
||||
content: |
|
||||
Use **Send Email** when an application needs to send reusable template content. The service resolves the template version, variables, sender and delivery provider, then records the prepared message in **Messages**. Queuing and delivery are separate steps. Inspect the returned result and message state rather than assuming that accepting the request means the recipient has received the email.
|
||||
- key: process:email_send_ad_hoc;slot:help
|
||||
summary: 'Prepare and queue an email from supplied subject and body content.'
|
||||
content: |
|
||||
Use **Send Ad Hoc Email** for content supplied directly instead of through a reusable template. The request still passes through the email service and durable outbox, including recipient, provider and suppression checks. Inspect the result in **Messages** and its **Attempts**. A queued result means the request was stored for sending, not that delivery has already succeeded.
|
||||
- key: process:email_new_version;slot:help
|
||||
summary: 'Create a new editable draft version of the selected email template.'
|
||||
content: |
|
||||
Use **New Version** when you need to change a template without editing the active version. Review the selected template, then continue to create a draft. Open its **Versions** to edit the content and sample variables. Check **Preview** before using **Activate Version**. Creating a draft alone does not select it for normal outgoing messages or approve it for protected health information.
|
||||
- key: process:email_activate_version;slot:help
|
||||
summary: 'Make a reviewed draft the active version of its email template.'
|
||||
content: |
|
||||
Use **Activate Version** after checking the draft’s subject, body and sample preview. Review the selected version before continuing. Activation changes the version used for subsequent template sends; it does not regenerate messages already in the outbox. Approval for protected health information is a separate decision. Open the parent **Template** afterward to check its active and approved version references.
|
||||
- key: process:email_approve_phi;slot:help
|
||||
summary: 'Record approval of a template version for protected health information.'
|
||||
content: |
|
||||
Use **Approve For PHI** only after reviewing the selected version and its variable declarations for the intended protected health information. This is an email administrator action and records approval for a specific version. Approval does not make every provider suitable for sensitive delivery. Check the parent **Template** and the chosen **Provider** when investigating why a protected message was refused.
|
||||
- key: process:email_reprocess_webhook;slot:help
|
||||
summary: 'Return a failed provider notification to the processing queue.'
|
||||
content: |
|
||||
Use **Reprocess** from **Webhook Inbox** after investigating a failed notification. Review the selected records and correct the cause before retrying. Eligible failed rows return to pending processing; the inbox processor later turns them into delivery events and related updates. This action retries notification handling, not the original email send. Check the inbox status and error after processing runs again.
|
||||
- key: process:email_process_webhooks;slot:help
|
||||
summary: 'Process due provider notifications into email events and delivery updates.'
|
||||
content: |
|
||||
This background action processes notifications waiting in **Webhook Inbox**. Use its results when investigating why a provider’s notification has not appeared under a message’s **Events**. Processing can update delivery state and suppressions as appropriate. Failed notifications retain diagnostic and retry information. Review that information before using **Reprocess** on a failed inbox record.
|
||||
- key: process:email_dispatch;slot:help
|
||||
summary: 'Attempt delivery of outgoing messages that are due for sending.'
|
||||
content: |
|
||||
The dispatcher claims eligible messages from the outbox and attempts delivery through their providers. Use **Messages** and **Attempts** to inspect what happened, including provider errors and scheduled retries. Dispatch honors the service’s delivery controls and records each attempt. A completed send attempt can still be followed by a delivery, bounce or complaint event reported by the provider.
|
||||
- key: process:email_maintain;slot:help
|
||||
summary: 'Maintain the outgoing email queue and recover expired delivery claims.'
|
||||
content: |
|
||||
This background action performs the dispatcher’s queue maintenance. Use it when investigating messages whose worker claim expired or whose delivery state needs maintenance before another attempt. Review **Messages** and **Attempts** for the resulting state and error details. Maintenance is separate from processing provider notifications, which are handled through **Webhook Inbox**, and from clearing old retained message content.
|
||||
- key: process:email_retention;slot:help
|
||||
summary: 'Clear expired email content and remove old delivery detail according to retention settings.'
|
||||
content: |
|
||||
Retention limits how long detailed email content remains stored. This action clears old bodies and saved variable values. Notification and marketing message records remain so their unsubscribe links keep working; other completed messages and their attempts and events can be deleted when message retention expires. Use **Messages** to inspect retained history. Missing old content can reflect retention rather than a sending failure.
|
||||
- key: widget:email_provider_children;slot:label
|
||||
summary: 'Messages sent through this provider.'
|
||||
- key: widget:email_message_children;slot:label
|
||||
summary: 'Sending attempts for this message.'
|
||||
- key: widget:email_template_children;slot:label
|
||||
summary: 'Versions of this email template.'
|
||||
- key: widget:email_webhook_inbox_children;slot:label
|
||||
summary: 'Notifications received from this provider.'
|
||||
- key: widget:email_provider_sender_address_children;slot:label
|
||||
summary: 'Sender addresses configured for this provider.'
|
||||
- key: widget:email_event_children;slot:label
|
||||
summary: 'Delivery and engagement events for this message.'
|
||||
- key: widget:emailHealth;slot:label
|
||||
summary: 'Central Email health status, with a link to its checks in System Health.'
|
||||
- key: widget:emailHome;slot:label
|
||||
summary: 'Email delivery totals, provider activity and recent messages visible to you.'
|
||||
- key: widget:emailTemplatePreview;slot:label
|
||||
summary: 'A rendered preview of this email template version using sample variables.'
|
||||
- key: table:email_message;field:rfcMessageId
|
||||
summary: The stable Message-ID used in the outgoing email headers.
|
||||
content: |
|
||||
Generated when the message is queued and reused on delivery attempts. This identifies the message independently of **{{label:email_message.providerMessageId}}**.
|
||||
- key: process:email_provider.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_provider.bulkInsert}}** on **{{label:email_provider}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_provider.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_provider.bulkEdit}}** on **{{label:email_provider}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_provider.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_provider.bulkEditWithFile}}** on **{{label:email_provider}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_provider.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_provider.bulkDelete}}** on **{{label:email_provider}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: process:email_provider_sender_address.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_provider_sender_address.bulkInsert}}** on **{{label:email_provider_sender_address}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_provider_sender_address.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_provider_sender_address.bulkEdit}}** on **{{label:email_provider_sender_address}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_provider_sender_address.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_provider_sender_address.bulkEditWithFile}}** on **{{label:email_provider_sender_address}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_provider_sender_address.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_provider_sender_address.bulkDelete}}** on **{{label:email_provider_sender_address}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: process:email_template.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_template.bulkInsert}}** on **{{label:email_template}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_template.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_template.bulkEdit}}** on **{{label:email_template}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_template.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_template.bulkEditWithFile}}** on **{{label:email_template}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_template.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_template.bulkDelete}}** on **{{label:email_template}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: process:email_template_version.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_template_version.bulkInsert}}** on **{{label:email_template_version}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_template_version.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_template_version.bulkEdit}}** on **{{label:email_template_version}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_template_version.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_template_version.bulkEditWithFile}}** on **{{label:email_template_version}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_template_version.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_template_version.bulkDelete}}** on **{{label:email_template_version}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: process:email_brand.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_brand.bulkInsert}}** on **{{label:email_brand}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_brand.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_brand.bulkEdit}}** on **{{label:email_brand}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_brand.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_brand.bulkEditWithFile}}** on **{{label:email_brand}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_brand.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_brand.bulkDelete}}** on **{{label:email_brand}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: process:email_suppression.bulkInsert;slot:help
|
||||
summary: Import new records from a file after mapping columns and reviewing validation.
|
||||
content: |
|
||||
Use **{{label:email_suppression.bulkInsert}}** on **{{label:email_suppression}}**. Map your file columns to the fields you want to create, then review validation messages before continuing. Check the resulting records afterward. Email permissions and validation still apply.
|
||||
- key: process:email_suppression.bulkEdit;slot:help
|
||||
summary: Update selected records with shared field values after reviewing the selection.
|
||||
content: |
|
||||
Use **{{label:email_suppression.bulkEdit}}** on **{{label:email_suppression}}**. Choose the fields and values to apply to your selection, then review the proposed changes before continuing. Email permissions and validation still apply; review any skipped or rejected records.
|
||||
- key: process:email_suppression.bulkEditWithFile;slot:help
|
||||
summary: Update existing records by matching them to rows in a file.
|
||||
content: |
|
||||
Use **{{label:email_suppression.bulkEditWithFile}}** on **{{label:email_suppression}}**. Map your file columns and check how rows match existing records. Review the proposed updates before continuing and check the result for rejected rows. Email permissions and validation still apply.
|
||||
- key: process:email_suppression.bulkDelete;slot:help
|
||||
summary: Delete selected records after reviewing the selection and its dependencies.
|
||||
content: |
|
||||
Use **{{label:email_suppression.bulkDelete}}** on **{{label:email_suppression}}**. Review your selection carefully before continuing. Records still needed by other email records may be refused. Check the result for records that could not be removed. Deletion does not recall messages already sent.
|
||||
- key: possibleValueSource:email_provider_kind;slot:help
|
||||
summary: Delivery services and protocols available for provider configuration.
|
||||
content: |
|
||||
The available choices are {{values:email_provider_kind}}.
|
||||
- key: possibleValueSource:email_security;slot:help
|
||||
summary: Connection security choices for SMTP providers.
|
||||
content: |
|
||||
The available choices are {{values:email_security}}.
|
||||
- key: possibleValueSource:email_health_status;slot:help
|
||||
summary: Health states recorded for a delivery provider.
|
||||
content: |
|
||||
The available choices are {{values:email_health_status}}.
|
||||
- key: possibleValueSource:email_message_status;slot:help
|
||||
summary: Delivery states recorded for outgoing messages.
|
||||
content: |
|
||||
The available choices are {{values:email_message_status}}.
|
||||
- key: possibleValueSource:email_attempt_outcome;slot:help
|
||||
summary: Outcomes of individual sending attempts.
|
||||
content: |
|
||||
The available choices are {{values:email_attempt_outcome}}.
|
||||
- key: possibleValueSource:email_template_kind;slot:help
|
||||
summary: Source formats available for template content.
|
||||
content: |
|
||||
The available choices are {{values:email_template_kind}}.
|
||||
- key: possibleValueSource:email_version_status;slot:help
|
||||
summary: Lifecycle states of a template version.
|
||||
content: |
|
||||
The available choices are {{values:email_version_status}}.
|
||||
- key: possibleValueSource:email_suppression_scope;slot:help
|
||||
summary: Categories of email that an address restriction can block.
|
||||
content: |
|
||||
The available choices are {{values:email_suppression_scope}}.
|
||||
- key: possibleValueSource:email_suppression_reason;slot:help
|
||||
summary: Reasons an address can be blocked from receiving email.
|
||||
content: |
|
||||
The available choices are {{values:email_suppression_reason}}.
|
||||
- key: possibleValueSource:email_category;slot:help
|
||||
summary: Message categories used for suppression and unsubscribe rules.
|
||||
content: |
|
||||
The available choices are {{values:email_category}}.
|
||||
- key: possibleValueSource:email_inbox_status;slot:help
|
||||
summary: Processing states for received provider notifications.
|
||||
content: |
|
||||
The available choices are {{values:email_inbox_status}}.
|
||||
- key: possibleValueSource:email_event_type;slot:help
|
||||
summary: Kinds of delivery and engagement events reported by providers.
|
||||
content: |
|
||||
The available choices are {{values:email_event_type}}.
|
||||
- key: possibleValueSource:email_bounce_type;slot:help
|
||||
summary: Bounce classifications reported by providers.
|
||||
content: |
|
||||
The available choices are {{values:email_bounce_type}}.
|
||||
- key: possibleValueSource:email_brand;slot:help
|
||||
summary: Select the branding for email content.
|
||||
- key: possibleValueSource:email_event;slot:help
|
||||
summary: Select a delivery or engagement event.
|
||||
- key: possibleValueSource:email_message;slot:help
|
||||
summary: Select an outgoing message.
|
||||
- key: possibleValueSource:email_message_attempt;slot:help
|
||||
summary: Select a sending attempt.
|
||||
- key: possibleValueSource:email_provider;slot:help
|
||||
summary: Select a delivery provider.
|
||||
- key: possibleValueSource:email_provider_sender_address;slot:help
|
||||
summary: Select a sender address for a delivery provider.
|
||||
- key: possibleValueSource:email_suppression;slot:help
|
||||
summary: Select an address restriction.
|
||||
- key: possibleValueSource:email_template;slot:help
|
||||
summary: Select a reusable email template.
|
||||
- key: possibleValueSource:email_template_version;slot:help
|
||||
summary: Select a version of an email template.
|
||||
- key: possibleValueSource:email_webhook_inbox;slot:help
|
||||
summary: Select a received provider notification.
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.template.CrmLegacyTemplateRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populated legacy fixtures preserve behavior even when production has no rows.
|
||||
*******************************************************************************/
|
||||
class CrmLegacyTemplateRendererTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Unknown syntax and substituted values stay literal; replacements run once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void literalSinglePassAndBodyPreference()
|
||||
{
|
||||
QRecord template = new QRecord().withValue("id", 7).withValue("ownerQbit", "crm");
|
||||
QRecord version = new QRecord().withValue("id", 9).withValue("subject", "Hi {{contact.firstName}}")
|
||||
.withValue("bodySource", "<p>html</p>")
|
||||
.withValue("textSource", "{{contact.lastName}} {{company.name}} {{owner.name}} {{unsubscribeUrl}} {{unknown}} {{#if x}}");
|
||||
QRecord rendered = CrmLegacyTemplateRenderer.render(template, version,
|
||||
Map.of("contact.firstName", "{{contact.lastName}}", "contact.lastName", "$1\\value", "company.name", "A&B", "owner.name", "42", "unsubscribeUrl", "https://example.test/u"), null, null);
|
||||
assertThat(rendered.getValueString("subject")).isEqualTo("Hi {{contact.lastName}}");
|
||||
assertThat(rendered.getValueString("markdownBody")).isEqualTo("$1\\value A&B 42 https://example.test/u {{unknown}} {{#if x}}");
|
||||
assertThat(rendered.getValueInteger("templateId")).isEqualTo(7);
|
||||
assertThat(rendered.getValueInteger("templateVersionId")).isEqualTo(9);
|
||||
version.setValue("textSource", null);
|
||||
assertThat(CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null).getValueString("markdownBody")).isEqualTo("<p>html</p>");
|
||||
assertThat(CrmLegacyTemplateRenderer.render(template, version, Map.of(), "override", "body").getValueString("subject")).isEqualTo("override");
|
||||
version.setValue("textSource", "");
|
||||
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("required");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility never grants PHI export or accepts header injection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsProtectedOrInvalidContent()
|
||||
{
|
||||
QRecord template = new QRecord().withValue("ownerQbit", "crm").withValue("phiApproved", true);
|
||||
QRecord version = new QRecord().withValue("subject", "subject").withValue("bodySource", "body");
|
||||
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("cannot be PHI");
|
||||
template.setValue("phiApproved", false);
|
||||
template.setValue("ownerQbit", "other");
|
||||
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), null, null)).hasMessageContaining("CRM ownership");
|
||||
template.setValue("ownerQbit", "crm");
|
||||
assertThatThrownBy(() -> CrmLegacyTemplateRenderer.render(template, version, Map.of(), "subject\nheader", null)).hasMessageContaining("Invalid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.render.ContrastCheck;
|
||||
import com.kingsrook.qbits.email.render.EmailBrands;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-email/email-defaults.
|
||||
** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against
|
||||
** the qbit's test schema: the brand appears once, a second update runs nothing,
|
||||
** an edit survives, a host with its own brand or without the table is left
|
||||
** alone, and the seed never changes how mail is rendered.
|
||||
*******************************************************************************/
|
||||
class EmailDefaultsChangelogTest
|
||||
{
|
||||
static final String CHANGELOG = "db/liquibase/qbit-email/email-defaults.changelog.sql";
|
||||
|
||||
/** the tables the changelog seeds. */
|
||||
static final List<String> SEEDED_TABLES = List.of("email_brand");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, update twice: one brand; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("neutral");
|
||||
assertThat(scalar(connection, "SELECT is_default FROM email_brand")).isEqualTo("FALSE");
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE email_brand SET label = 'Ours', is_default = TRUE");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT label FROM email_brand")).isEqualTo("Ours");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already configured a brand (of any name) gets nothing added,
|
||||
** so it can never end up with two default brands.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void leavesAHostWithItsOwnBrandAlone() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('acme', 'Acme', TRUE)");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("acme");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling the seed back (Liquibase also rolls back changesets it only marked
|
||||
** as ran) never deletes a brand the host already had.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollbackKeepsHostOwnedRows() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('neutral', 'Mine', TRUE)");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(1, "");
|
||||
connection.commit();
|
||||
assertThat(scalar(connection, "SELECT label FROM email_brand WHERE name = 'neutral'")).isEqualTo("Mine");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that has not created the table yet skips the changeset (marked
|
||||
** ran), rather than failing its deploy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aMissingTableMarksTheChangesetRan() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(false))
|
||||
{
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guarded, and only precondition forms that parse in Liquibase 4 and 5.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
String text = changelogText();
|
||||
assertThat(text).doesNotContain("precondition-table-exists");
|
||||
assertThat(text.split("(?m)^-- changeset ")).hasSize(2);
|
||||
assertThat(text).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand")
|
||||
.contains("-- rollback ").contains("WHERE NOT EXISTS (SELECT 1 FROM email_brand)");
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(text).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded brand passes the qbit's own contrast checks, and being a
|
||||
** non-default brand it changes nothing about how mail renders until chosen.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theSeededBrandIsSafeAndChangesNothing() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
update(connection);
|
||||
QRecord brand = new QRecord();
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM email_brand"))
|
||||
{
|
||||
rs.next();
|
||||
for(String column : List.of("primary_color", "text_color", "background_color", "button_text_color", "dark_primary_color", "dark_text_color", "dark_background_color", "dark_button_text_color"))
|
||||
{
|
||||
brand.setValue(camel(column), rs.getString(column));
|
||||
}
|
||||
}
|
||||
ContrastCheck.validate(brand);
|
||||
EmailBrands.validate(brand);
|
||||
}
|
||||
|
||||
H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "neutral").withValue("label", "Neutral").withValue("primaryColor", "#2459a6")
|
||||
.withValue("isDefault", false));
|
||||
QRecord resolved = EmailBrands.resolve(null, null);
|
||||
assertThat(resolved.getValue("id")).as("the seeded brand is not picked up unless selected").isNull();
|
||||
assertThat(resolved.getValueString("label")).isEqualTo("Email");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** snake_case to camelCase.
|
||||
*******************************************************************************/
|
||||
private static String camel(String column)
|
||||
{
|
||||
StringBuilder camel = new StringBuilder();
|
||||
boolean up = false;
|
||||
for(char c : column.toCharArray())
|
||||
{
|
||||
if(c == '_')
|
||||
{
|
||||
up = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
camel.append(up ? Character.toUpperCase(c) : c);
|
||||
up = false;
|
||||
}
|
||||
}
|
||||
return (camel.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables.
|
||||
*******************************************************************************/
|
||||
static Connection freshDatabase(boolean withTables) throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
|
||||
if(withTables)
|
||||
{
|
||||
String script;
|
||||
try(InputStream in = EmailDefaultsChangelogTest.class.getResourceAsStream("/email-h2.sql"))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : script.split(";"))
|
||||
{
|
||||
if(!sql.isBlank())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the changelog with real Liquibase.
|
||||
*******************************************************************************/
|
||||
static void update(Connection connection) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// not closed: closing Liquibase would close the test connection //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update("");
|
||||
connection.commit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String changelogText() throws Exception
|
||||
{
|
||||
try(InputStream stream = EmailDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG))
|
||||
{
|
||||
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
return (rs.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,18 +21,34 @@
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetup;
|
||||
import com.kingsrook.qbits.email.delivery.*;
|
||||
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import com.kingsrook.qbits.email.transport.*;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransport;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportException;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportRegistry;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportResult;
|
||||
import com.kingsrook.qbits.email.transport.SmtpEmailTransport;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
@@ -41,16 +57,23 @@ import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.*;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.*;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
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.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import jakarta.mail.Multipart;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Delivery Test.
|
||||
@@ -398,6 +421,39 @@ class EmailDeliveryTest
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A lost acknowledgement retries the stored identity through the real SMTP sink.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void smtpRetryPreservesStoredMessageId() throws Exception
|
||||
{
|
||||
var queued = EmailService.send(request().withIdempotencyKey("job:1:row:mail"));
|
||||
String stored = message(queued.messageId()).getValueString("rfcMessageId");
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
var dispatcher = fake((envelope, settings) ->
|
||||
{
|
||||
assertThat(envelope.getRfcMessageId()).isEqualTo(stored);
|
||||
var sent = new SmtpEmailTransport().send(envelope, settings);
|
||||
if(calls.incrementAndGet() == 1)
|
||||
{
|
||||
throw new EmailTransportException("Lost acknowledgement", false);
|
||||
}
|
||||
return (sent);
|
||||
});
|
||||
assertThat(dispatcher.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(301), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.SENT);
|
||||
assertThat(mail.waitForIncomingEmail(2000, 2)).isTrue();
|
||||
assertThat(mail.getReceivedMessages()).hasSize(2);
|
||||
for(var received : mail.getReceivedMessages())
|
||||
{
|
||||
assertThat(received.getMessageID()).isEqualTo(stored);
|
||||
assertThat(received.getHeader("X-QQQ-Email-Id")).containsExactly(queued.messageId().toString());
|
||||
}
|
||||
assertThat(message(queued.messageId()).getValueString("rfcMessageId")).isEqualTo(stored);
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Smtp Authentication And Secret Resolution Only At Send.
|
||||
*******************************************************************************/
|
||||
@@ -477,8 +533,8 @@ class EmailDeliveryTest
|
||||
var second = EmailService.send(request());
|
||||
fake((e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("throttle", false).withRetryAfter(Duration.ofHours(2));
|
||||
})
|
||||
throw new EmailTransportException("throttle", false).withRetryAfter(Duration.ofHours(2));
|
||||
})
|
||||
.runOnce();
|
||||
assertThat(message(second.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(3960));
|
||||
@@ -494,8 +550,8 @@ class EmailDeliveryTest
|
||||
assertThat(
|
||||
fake((e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("rejected", true);
|
||||
})
|
||||
throw new EmailTransportException("rejected", true);
|
||||
})
|
||||
.sendNow(queued.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
|
||||
var future = EmailService.send(request());
|
||||
@@ -583,6 +639,48 @@ class EmailDeliveryTest
|
||||
.isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-out messages are one mailbox per send, before suppression can trim them.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOTIFICATION", "MARKETING"})
|
||||
void unsubscribeRequiresOneToRecipient(String category) throws Exception
|
||||
{
|
||||
EmailRuntime.config().withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "test-unsubscribe-key");
|
||||
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "postal").withValue("isDefault", true).withValue("postalAddress", "123 Test Street"));
|
||||
for(EmailRequest invalid : List.of(request().withTo(List.of("person@example.com", "other@example.com")),
|
||||
request().withTo("person@example.com, other@example.com"), request().withCc("other@example.com"),
|
||||
request().withBcc("other@example.com"), request().withTo(List.of()).withCc("other@example.com"),
|
||||
request().withTo(List.of()).withBcc("other@example.com"), request().withTo(List.of()), request().withCc("person@example.com")))
|
||||
{
|
||||
var result = EmailService.send(invalid.withCategory(EmailCategory.valueOf(category)));
|
||||
assertThat(result.status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(result.reasons()).containsExactly("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient.");
|
||||
}
|
||||
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "other@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"));
|
||||
assertThat(EmailService.send(request().withCc("other@example.com").withCategory(EmailCategory.valueOf(category))).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transactional mail and notification mail without opt-out retain multi-party delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void multiRecipientWithoutUnsubscribeStillQueues() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "test-unsubscribe-key");
|
||||
var result = EmailService.send(request().withTo(List.of("person@example.com, other@example.com")).withCc("copy@example.com").withBcc("blind@example.com"));
|
||||
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(message(result.messageId()).getValueString("unsubscribeUrl")).isNull();
|
||||
assertThat(new EmailDispatcher().runOnce().sent()).isEqualTo(1);
|
||||
assertThat(mail.getReceivedMessages()).hasSize(4);
|
||||
assertThat(mail.getReceivedMessages()[0].getAllRecipients()).hasSize(3);
|
||||
EmailRuntime.config().withSecretResolver(name -> null);
|
||||
result = EmailService.send(request().withCc("copy@example.com").withBcc("blind@example.com").withCategory(EmailCategory.NOTIFICATION));
|
||||
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(message(result.messageId()).getValueString("unsubscribeUrl")).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rate Limits Do Not Consume Attempts And Send After Is Honored.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -30,9 +30,9 @@ import java.util.Set;
|
||||
import com.kingsrook.qbits.email.app.EmailHome;
|
||||
import com.kingsrook.qbits.email.metadata.EmailEnums;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.render.ContrastCheck;
|
||||
import com.kingsrook.qbits.email.render.EmailLayout;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
@@ -66,7 +66,9 @@ class EmailHomePolishTest
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(c -> { });
|
||||
H2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
H2Support.provider(25);
|
||||
}
|
||||
@@ -91,6 +93,12 @@ class EmailHomePolishTest
|
||||
.withValue("sentDate", NOW.minusSeconds(86400)).withValue("deliveredDate", NOW.plusSeconds(1))
|
||||
.withValue("deadLetterDate", NOW).withValue("bouncedDate", NOW.minusSeconds(7 * 86400))
|
||||
.withValue("complainedDate", NOW.minusSeconds(7 * 86400 + 1)));
|
||||
for(String type : List.of("DELIVERED", "BOUNCED", "COMPLAINED"))
|
||||
{
|
||||
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", type).withValue("eventKey", type)
|
||||
.withValue("occurredDate", "DELIVERED".equals(type) ? NOW.plusSeconds(1)
|
||||
: NOW.minusSeconds(7 * 86400 + ("COMPLAINED".equals(type) ? 1 : 0))));
|
||||
}
|
||||
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "a@example.test").withValue("scope", "ALL").withValue("reason", "MANUAL"));
|
||||
var document = Jsoup.parse(EmailHome.overview().getHtml());
|
||||
var tiles = document.select("body > div > div > div");
|
||||
@@ -100,8 +108,8 @@ class EmailHomePolishTest
|
||||
assertThat(tiles.get(i).child(0).attr("style")).contains("font-size:1.6rem");
|
||||
assertThat(tiles.get(i).child(0).attr("style").contains("#d64545")).isEqualTo(i == 2 || i == 3);
|
||||
}
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHealth", "emailHome");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -111,9 +119,10 @@ class EmailHomePolishTest
|
||||
void stackedBarsAndCompactAxes() throws Exception
|
||||
{
|
||||
int number = 0;
|
||||
Integer messageId = SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("status", "QUEUED")).getValueInteger("id");
|
||||
for(String outcome : List.of("SUCCEEDED", "TRANSIENT_FAILURE", "PROVIDER_FAILURE", "PERMANENT_FAILURE", "LEASE_EXPIRED", "IN_FLIGHT"))
|
||||
{
|
||||
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", 1).withValue("attemptNumber", ++number)
|
||||
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", messageId).withValue("attemptNumber", ++number)
|
||||
.withValue("outcome", outcome).withValue("endDate", NOW));
|
||||
}
|
||||
for(var reason : EmailEnums.SuppressionReason.values())
|
||||
@@ -139,7 +148,7 @@ class EmailHomePolishTest
|
||||
** Twenty newest linked rows wrap long text, mask addresses, and hide PHI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentRowsAndProviderHealth() throws Exception
|
||||
void recentRowsExcludeProviderDiagnostics() throws Exception
|
||||
{
|
||||
for(int i = 0; i < 21; i++)
|
||||
{
|
||||
@@ -157,13 +166,12 @@ class EmailHomePolishTest
|
||||
assertThat(rows.first().attr("href")).isEqualTo("/app/email_message/21");
|
||||
assertThat(rows.last().attr("href")).isEqualTo("/app/email_message/2");
|
||||
assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry");
|
||||
assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "<broken>", "min-width:0", "overflow-wrap:anywhere")
|
||||
.doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T");
|
||||
assertThat(document.select("section").get(1).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
|
||||
assertThat(html).contains("Secure message", "p***@", "min-width:0", "overflow-wrap:anywhere")
|
||||
.doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T", "Provider health", "<broken>", "Per-minute headroom");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Disabled", "No limit", "Provider health");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("Provider health needs email.admin").doesNotContain("<broken>");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "<broken>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -20,14 +20,24 @@
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.metadata.*;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.permissions.*;
|
||||
import com.kingsrook.qbits.email.processes.*;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.*;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.AdHocEmailRenderer;
|
||||
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.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
@@ -36,10 +46,14 @@ 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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Metadata Test.
|
||||
@@ -73,7 +87,7 @@ class EmailMetadataTest
|
||||
void producerStandardsAndIntegrationFields() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(instance.getTables()).hasSize(7);
|
||||
assertThat(instance.getTables()).hasSize(10);
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
assertThat(table.getName()).matches("email_[a-z_]+");
|
||||
@@ -412,7 +426,7 @@ class EmailMetadataTest
|
||||
"MAILGUN",
|
||||
"GMAIL_API",
|
||||
"future"))
|
||||
{
|
||||
{
|
||||
assertThat(
|
||||
new EmailQBitConfig()
|
||||
.withAllowedEnvNames(Set.of("CUSTOM"))
|
||||
@@ -495,8 +509,8 @@ class EmailMetadataTest
|
||||
config.validate(QContext.getQInstance(), errors);
|
||||
assertThat(errors).isEmpty();
|
||||
var scheduled = EmailProcesses.processes(config).stream().filter(p -> p.getSchedule() != null).toList();
|
||||
assertThat(scheduled).hasSize(3).allMatch(p -> p.getIsHidden());
|
||||
assertThat(scheduled).extracting(p -> p.getSchedule().getRepeatSeconds()).containsExactly(15, 300, 86400);
|
||||
assertThat(scheduled).hasSize(4).allMatch(p -> p.getIsHidden());
|
||||
assertThat(scheduled).extracting(p -> p.getSchedule().getRepeatSeconds()).containsExactly(30, 15, 300, 86400);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.render.EmailUnsubscribeLinks;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplates;
|
||||
import com.kingsrook.qbits.email.template.EmailVersions;
|
||||
import com.kingsrook.qbits.email.tracking.EmailUnsubscribe;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.NOW;
|
||||
import static com.kingsrook.qbits.email.tracking.TrackingTestSupport.SECRET;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention of opt-out mail and the consumer-facing template API.
|
||||
*******************************************************************************/
|
||||
class EmailPhaseCReviewTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Isolated database with opt-out links configured and message retention on.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(c -> c.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> SECRET).withMessageRetentionDays(30));
|
||||
H2Support.provider(25);
|
||||
EmailRuntime.setClock(Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear thread and clock state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
EmailRuntime.setClock(null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An old final message with content, in the given category.
|
||||
*******************************************************************************/
|
||||
private static QRecord oldMessage(String category, String address) throws Exception
|
||||
{
|
||||
return (SystemWrites.insertOne(EmailTables.MESSAGE, new QRecord().withValue("providerId", H2Support.all(EmailTables.PROVIDER).getFirst().getValue("id"))
|
||||
.withValue("status", "SENT").withValue("category", category)
|
||||
.withValue("toAddresses", address).withValue("sentDate", NOW.minus(Duration.ofDays(100))).withValue("htmlBody", "<p>hi</p>")
|
||||
.withValue("textBody", "hi").withValue("variablesSnapshot", "{\"name\":\"x\"}")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Old marketing and notification mail is kept (content cleared) so its
|
||||
** unsubscribe link works; transactional mail is deleted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retentionKeepsOptOutMessagesAndTheirUnsubscribeLinks() throws Exception
|
||||
{
|
||||
QRecord marketing = oldMessage("MARKETING", "pat@example.com");
|
||||
QRecord notification = oldMessage("NOTIFICATION", "sam@example.com");
|
||||
QRecord transactional = oldMessage("TRANSACTIONAL", "lee@example.com");
|
||||
String url = EmailUnsubscribeLinks.create(marketing.getValueString("id"), "pat@example.com", "MARKETING");
|
||||
assertThat(url).isNotNull();
|
||||
EmailProcesses.retain();
|
||||
assertThat(EmailStore.get(EmailTables.MESSAGE, transactional.getValueInteger("id"), null)).isNull();
|
||||
for(QRecord kept : List.of(marketing, notification))
|
||||
{
|
||||
QRecord row = EmailStore.get(EmailTables.MESSAGE, kept.getValueInteger("id"), null);
|
||||
assertThat(row).isNotNull();
|
||||
assertThat(row.getValueString("htmlBody")).isNull();
|
||||
assertThat(row.getValueString("textBody")).isNull();
|
||||
assertThat(row.getValueString("variablesSnapshot")).isNull();
|
||||
}
|
||||
new EmailUnsubscribe().unsubscribe(url.substring(url.lastIndexOf('/') + 1));
|
||||
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueString("address")).isEqualTo("pat@example.com");
|
||||
assertThat(row.getValueString("scope")).isEqualTo("MARKETING");
|
||||
});
|
||||
EmailProcesses.retain();
|
||||
assertThat(EmailStore.get(EmailTables.MESSAGE, marketing.getValueInteger("id"), null)).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A complete definition used by the template tests.
|
||||
*******************************************************************************/
|
||||
private static EmailTemplateDefinition definition()
|
||||
{
|
||||
return (new EmailTemplateDefinition().withName("order_ready").withLabel("Order ready").withCategory(EmailCategory.TRANSACTIONAL)
|
||||
.withSubject("Order {{order_number}} is ready").withBody("Hi {{customer_name}}, order {{order_number}} is ready.")
|
||||
.withVariable("order_number", "Order number", "STRING", true, "PUBLIC")
|
||||
.withVariable("customer_name", "Customer name", "STRING", true, null)
|
||||
.withSampleVariables("{\"order_number\":\"A-1\",\"customer_name\":\"Pat\"}").withOwnerQbit("orders"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Created once as an ACTIVE version 1 that sends; the second call changes nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ensureCreatesOnceAndIsIdempotent() throws Exception
|
||||
{
|
||||
assertThat(EmailTemplates.ensure(definition())).isTrue();
|
||||
assertThat(EmailTemplates.ensure(definition())).isFalse();
|
||||
QRecord template = H2Support.all(EmailTables.TEMPLATE).getFirst();
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).hasSize(1);
|
||||
assertThat(template.getValueString("ownerQbit")).isEqualTo("orders");
|
||||
assertThat(template.getValueString("kind")).isEqualTo("MARKDOWN");
|
||||
assertThat(template.getValueBoolean("phiApproved")).isFalse();
|
||||
List<QRecord> versions = H2Support.all(EmailTables.VERSION);
|
||||
assertThat(versions).hasSize(1);
|
||||
assertThat(versions.getFirst().getValueString("status")).isEqualTo("ACTIVE");
|
||||
assertThat(versions.getFirst().getValueInteger("versionNumber")).isEqualTo(1);
|
||||
assertThat(versions.getFirst().getValueString("variablesSchema")).contains("order_number").contains("PUBLIC");
|
||||
assertThat(template.getValue("activeVersionId")).isEqualTo(versions.getFirst().getValue("id"));
|
||||
EmailSendResult result = EmailService.send(new EmailRequest().withTemplateName("order_ready").withTo("pat@example.com")
|
||||
.withVariable("order_number", "A-1").withVariable("customer_name", "Pat"));
|
||||
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An edited template keeps its edits when the defining qbit calls ensure again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ensureDoesNotTouchAnEditedTemplate() throws Exception
|
||||
{
|
||||
assertThat(EmailTemplates.ensure(definition())).isTrue();
|
||||
QRecord template = H2Support.all(EmailTables.TEMPLATE).getFirst();
|
||||
QRecord draft = EmailVersions.newVersion(template.getValueInteger("id"));
|
||||
new UpdateAction().execute(new UpdateInput(EmailTables.VERSION).withRecords(List.of(new QRecord().withValue("id", draft.getValue("id"))
|
||||
.withValue("bodySource", "Edited by a person for {{order_number}}"))));
|
||||
EmailVersions.activate(draft.getValueInteger("id"));
|
||||
assertThat(EmailTemplates.ensure(definition().withBody("Changed again {{order_number}} {{customer_name}}"))).isFalse();
|
||||
assertThat(H2Support.all(EmailTables.VERSION)).hasSize(2);
|
||||
QRecord active = EmailStore.get(EmailTables.VERSION, EmailStore.get(EmailTables.TEMPLATE, template.getValueInteger("id"), null).getValueInteger("activeVersionId"), null);
|
||||
assertThat(active.getValueInteger("versionNumber")).isEqualTo(2);
|
||||
assertThat(active.getValueString("bodySource")).isEqualTo("Edited by a person for {{order_number}}");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid definitions are refused with plain reasons and write nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ensureRefusesInvalidDefinitions() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> EmailTemplates.ensure(new EmailTemplateDefinition().withName("Bad Name").withKind("PDF")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Name must be snake_case").hasMessageContaining("Label is required").hasMessageContaining("Category is required")
|
||||
.hasMessageContaining("Kind must be").hasMessageContaining("Subject is required").hasMessageContaining("Body is required");
|
||||
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withVariablesSchema("[]")))
|
||||
.hasMessageContaining("not both");
|
||||
assertThatThrownBy(() -> EmailTemplates.ensure(new EmailTemplateDefinition().withName("x").withLabel("X").withCategory(EmailCategory.SECURITY)
|
||||
.withSubject("S").withBody("B").withVariablesSchema("{").withSampleVariables("[")))
|
||||
.hasMessageContaining("Invalid variables schema").hasMessageContaining("Variables must be a JSON object");
|
||||
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withBody("Hi {{undeclared_thing}}")))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("refused");
|
||||
assertThatThrownBy(() -> EmailTemplates.ensure(definition().withSampleVariables("{}")))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("refused");
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).isEmpty();
|
||||
assertThat(H2Support.all(EmailTables.VERSION)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -20,20 +20,28 @@
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import com.icegreen.greenmail.util.*;
|
||||
import com.kingsrook.qbits.email.delivery.*;
|
||||
import com.kingsrook.qbits.email.metadata.*;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.processes.*;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetup;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.*;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Processes Test.
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.email;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -47,8 +48,33 @@ class EmailQBitProducerTest
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getNamespace()).isNull();
|
||||
assertThat(producer.getQBitConfig().getBackendName()).isEqualTo("memory");
|
||||
String artifactVersion = System.getProperty("qbit.artifactVersion");
|
||||
assertThat(artifactVersion).isNotBlank();
|
||||
assertThat(EmailQBitProducer.VERSION).isEqualTo(artifactVersion);
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion);
|
||||
assertThat(producer.produce(instance)).isNotNull();
|
||||
assertThat(instance.getQBits()).isNotEmpty();
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-email");
|
||||
assertThat(bundle.getVersion()).isEqualTo(artifactVersion).isEqualTo(EmailQBitProducer.VERSION);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The app help follows the configured app name even without a help table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersHelpForCustomAppName() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory"));
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withAppName("mail"))
|
||||
.produce(instance);
|
||||
assertThat(HelpBundles.of(instance).resolveWinningEntries())
|
||||
.containsKey(new HelpBundles.EntryKey("app:mail;slot:help", "ALL_SCREENS"))
|
||||
.doesNotContainKey(new HelpBundles.EntryKey("app:email;slot:help", "ALL_SCREENS"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -109,7 +109,7 @@ class EmailReviewFixesTest
|
||||
var schema = new VariableSchema("[{\"name\":\"items\",\"type\":\"LIST\"},{\"name\":\"value\",\"type\":\"STRING\"}]");
|
||||
var loop = engine.parse("{{#each items}}{{#each items}}{{/each}}{{/each}}", schema);
|
||||
assertThatThrownBy(() -> loop.render(Map.of("items", Collections.nCopies(100, "")), EmailTemplateEngine.Context.TEXT)).hasMessage("Template output is too large");
|
||||
assertThatThrownBy(() -> engine.parse("{{value}}", schema).render(Map.of("value", "\u00e9".repeat(600_000)), EmailTemplateEngine.Context.TEXT))
|
||||
assertThatThrownBy(() -> engine.parse("{{value}}", schema).render(Map.of("value", "é".repeat(600_000)), EmailTemplateEngine.Context.TEXT))
|
||||
.hasMessage("Template output is too large");
|
||||
assertThat(engine.parse("{{#if value}}".repeat(8) + "ok" + "{{/if}}".repeat(8), schema).render(Map.of("value", true), EmailTemplateEngine.Context.TEXT)).isEqualTo("ok");
|
||||
assertThatThrownBy(() -> engine.parse("{{#if value}}".repeat(9) + "{{/if}}".repeat(9), schema)).hasMessageContaining("8 levels");
|
||||
@@ -179,7 +179,10 @@ class EmailReviewFixesTest
|
||||
{
|
||||
assertThatThrownBy(() -> EmailTemplateEngine.validateHtml(source)).hasMessageContaining("must be quoted");
|
||||
}
|
||||
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Unsafe MJML reached compiler"); });
|
||||
EmailRuntime.config().withMjmlCompiler(source ->
|
||||
{
|
||||
throw new AssertionError("Unsafe MJML reached compiler");
|
||||
});
|
||||
QRecord mjml = EmailTemplatesTest.draft("includes", "MJML", "[]", "{}", "<mjml><mj-include path='secret'/></mjml>");
|
||||
assertThatThrownBy(() -> EmailVersions.activate(mjml.getValueInteger("id"))).hasMessage("MJML includes are not allowed");
|
||||
assertThat(preview(mjml)).contains("MJML includes are not allowed");
|
||||
@@ -192,7 +195,10 @@ class EmailReviewFixesTest
|
||||
void viewerNeverCompiles() throws Exception
|
||||
{
|
||||
QRecord version = EmailTemplatesTest.draft("viewer", "MJML", "[]", "{}", "<mjml/>");
|
||||
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Viewer compiled MJML"); });
|
||||
EmailRuntime.config().withMjmlCompiler(source ->
|
||||
{
|
||||
throw new AssertionError("Viewer compiled MJML");
|
||||
});
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(preview(version)).contains("Not compiled yet. Activate the version or use Preview as a manager.");
|
||||
QContext.getQSession().setPermissions(Set.of("email.manage"));
|
||||
@@ -270,7 +276,10 @@ class EmailReviewFixesTest
|
||||
QRecord original = EmailStore.get(EmailTables.MESSAGE, first.messageId(), null);
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", second.messageId()).withValue("htmlBody", original.getValue("htmlBody")));
|
||||
var dispatcher = new com.kingsrook.qbits.email.delivery.EmailDispatcher(new com.kingsrook.qbits.email.transport.EmailTransportRegistry()
|
||||
.withTransport("SMTP", (envelope, settings) -> { throw new AssertionError("Swapped ciphertext reached transport"); }));
|
||||
.withTransport("SMTP", (envelope, settings) ->
|
||||
{
|
||||
throw new AssertionError("Swapped ciphertext reached transport");
|
||||
}));
|
||||
assertThat(dispatcher.sendNow(second.messageId())).isEqualTo(com.kingsrook.qbits.email.delivery.EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
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.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider sender addresses: validation, sending rules, the fallback to the
|
||||
** provider's own from address and every contingent possible-value pair.
|
||||
*******************************************************************************/
|
||||
class EmailSenderAddressTest
|
||||
{
|
||||
private QRecord relay;
|
||||
private QRecord other;
|
||||
|
||||
/*******************************************************************************
|
||||
** Two providers on an isolated database.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
relay = H2Support.provider(25);
|
||||
other = H2Support.insert(EmailTables.PROVIDER, new QRecord().withValue("name", "other").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
|
||||
.withValue("port", 25).withValue("security", "NONE").withValue("fromAddress", "other@example.com").withValue("enabled", true).withValue("isDefault", false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear thread state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A sender address row for a provider.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(QRecord provider, String address)
|
||||
{
|
||||
return (new QRecord().withValue("providerId", provider.getValue("id")).withValue("address", address).withValue("enabled", true).withValue("isDefault", false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert through the table action and return the (possibly errored) result.
|
||||
*******************************************************************************/
|
||||
private static QRecord tryInsert(QRecord row) throws Exception
|
||||
{
|
||||
return (new InsertAction().execute(new InsertInput(EmailTables.SENDER).withRecords(List.of(row))).getRecords().get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A one-recipient request.
|
||||
*******************************************************************************/
|
||||
private static EmailRequest request(String providerName)
|
||||
{
|
||||
return (new EmailRequest().withProviderName(providerName).withTo("person@example.net").withSubject("Hello").withTextBody("Body"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Addresses are canonical lowercase and line breaks, bad addresses are refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void addressIsCanonicalAndSafe() throws Exception
|
||||
{
|
||||
QRecord saved = H2Support.insert(EmailTables.SENDER, row(relay, "Sales@Example.COM"));
|
||||
assertThat(saved.getValueString("address")).isEqualTo("sales@example.com");
|
||||
assertThat(tryInsert(row(relay, "a@example.com\nBcc: x@example.net")).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(row(relay, "not an address")).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(row(relay, "ok@example.com").withValue("displayName", "Evil\r\nBcc: x@example.net")).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(new QRecord().withValue("providerId", 9999).withValue("address", "x@example.com")).getErrors()).isNotEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The domain must equal the provider sending domain when one is set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void domainMustMatchSendingDomain() throws Exception
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", relay.getValue("id")).withValue("sendingDomain", "example.com"));
|
||||
assertThat(tryInsert(row(relay, "x@other.org")).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(row(relay, "x@EXAMPLE.com")).getErrors()).isNullOrEmpty();
|
||||
QRecord off = tryInsert(row(other, "x@other.org"));
|
||||
assertThat(off.getErrors()).isNullOrEmpty();
|
||||
var update = new UpdateAction().execute(new UpdateInput(EmailTables.PROVIDER).withRecords(List.of(new QRecord().withValue("id", other.getValue("id")).withValue("sendingDomain", "example.com"))));
|
||||
assertThat(update.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unique per provider; the same address may belong to another provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void addressIsUniquePerProvider() throws Exception
|
||||
{
|
||||
H2Support.insert(EmailTables.SENDER, row(relay, "sales@example.com"));
|
||||
assertThat(tryInsert(row(relay, "SALES@example.com")).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(row(other, "sales@example.com")).getErrors()).isNullOrEmpty();
|
||||
var both = new InsertAction().execute(new InsertInput(EmailTables.SENDER).withRecords(List.of(row(relay, "twin@example.com"), row(relay, "twin@example.com")))).getRecords();
|
||||
assertThat(both.stream().filter(r -> r.getErrors() != null && !r.getErrors().isEmpty())).isNotEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** At most one default per provider, and a default must be enabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void singleEnabledDefault() throws Exception
|
||||
{
|
||||
H2Support.insert(EmailTables.SENDER, row(relay, "a@example.com").withValue("isDefault", true));
|
||||
assertThat(tryInsert(row(relay, "b@example.com").withValue("isDefault", true)).getErrors()).isNotEmpty();
|
||||
assertThat(tryInsert(row(other, "c@example.com").withValue("isDefault", true)).getErrors()).isNullOrEmpty();
|
||||
assertThat(tryInsert(row(relay, "d@example.com").withValue("isDefault", true).withValue("enabled", false)).getErrors()).isNotEmpty();
|
||||
QRecord plain = H2Support.insert(EmailTables.SENDER, row(relay, "e@example.com"));
|
||||
var promote = new UpdateAction().execute(new UpdateInput(EmailTables.SENDER).withRecords(List.of(new QRecord().withValue("id", plain.getValue("id")).withValue("isDefault", true))));
|
||||
assertThat(promote.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleting a provider removes its sender addresses.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deletingProviderRemovesSenderAddresses() throws Exception
|
||||
{
|
||||
H2Support.insert(EmailTables.SENDER, row(other, "gone@example.com"));
|
||||
new DeleteAction().execute(new DeleteInput(EmailTables.PROVIDER).withPrimaryKey(other.getValue("id")));
|
||||
assertThat(H2Support.all(EmailTables.SENDER)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sender rows are admin-only, like providers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void senderRowsAreAdminOnly() throws Exception
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(EmailTables.SENDER);
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).isEqualTo("email");
|
||||
assertThat(EmailTables.MAIN).doesNotContain(EmailTables.SENDER);
|
||||
assertThat(QContext.getQInstance().getJoin(EmailTables.PROVIDER + "_join_" + EmailTables.SENDER)).isNotNull();
|
||||
assertThat(QContext.getQInstance().getWidget(EmailTables.SENDER + "_children")).isNotNull();
|
||||
QContext.getQSession().setPermissions(java.util.Set.of("email.manage"));
|
||||
var checker = new EmailPermissionChecker();
|
||||
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new InsertInput(EmailTables.SENDER), table)).hasMessageContaining("email.admin");
|
||||
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new QueryInput(EmailTables.SENDER), table)).hasMessageContaining("email.admin");
|
||||
QContext.getQSession().setPermissions(java.util.Set.of("email.admin"));
|
||||
checker.checkPermissionsThrowing(new InsertInput(EmailTables.SENDER), table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sending with a chosen id: ok, another provider's row, and a disabled row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendWithFromAddressId() throws Exception
|
||||
{
|
||||
QRecord mine = H2Support.insert(EmailTables.SENDER, row(relay, "Sales@example.com").withValue("displayName", "Sales Desk"));
|
||||
QRecord theirs = H2Support.insert(EmailTables.SENDER, row(other, "help@example.com"));
|
||||
QRecord off = H2Support.insert(EmailTables.SENDER, row(relay, "old@example.com").withValue("enabled", false));
|
||||
EmailSendResult ok = EmailService.send(request("relay").withFromAddressId(mine.getValueInteger("id")));
|
||||
assertThat(ok.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
QRecord stored = EmailStore.get(EmailTables.MESSAGE, ok.messageId(), null);
|
||||
assertThat(stored.getValueString("fromAddress")).contains("sales@example.com").contains("Sales Desk");
|
||||
assertThat(stored.getValueInteger("fromAddressId")).isEqualTo(mine.getValueInteger("id"));
|
||||
EmailSendResult wrong = EmailService.send(request("relay").withFromAddressId(theirs.getValueInteger("id")));
|
||||
assertThat(wrong.status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(wrong.reasons()).containsExactly("That from address is not set up for provider relay");
|
||||
EmailSendResult disabled = EmailService.send(request("relay").withFromAddressId(off.getValueInteger("id")));
|
||||
assertThat(disabled.status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(disabled.reasons()).containsExactly("That from address is not set up for provider relay");
|
||||
assertThat(EmailService.send(request("relay").withFromAddressId(4242)).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(EmailService.send(request("relay").withFromAddressId(mine.getValueInteger("id")).withFrom("other@example.com")).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Once a provider has rows an explicit from text must match an enabled one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fromTextMustMatchRowsWhenTheyExist() throws Exception
|
||||
{
|
||||
QRecord listed = H2Support.insert(EmailTables.SENDER, row(relay, "sales@example.com"));
|
||||
H2Support.insert(EmailTables.SENDER, row(relay, "old@example.com").withValue("enabled", false));
|
||||
EmailSendResult ok = EmailService.send(request("relay").withFrom("Sales Team <SALES@example.com>"));
|
||||
assertThat(ok.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
QRecord stored = EmailStore.get(EmailTables.MESSAGE, ok.messageId(), null);
|
||||
assertThat(stored.getValueString("fromAddress")).contains("Sales Team");
|
||||
assertThat(stored.getValueInteger("fromAddressId")).isEqualTo(listed.getValueInteger("id"));
|
||||
EmailSendResult unlisted = EmailService.send(request("relay").withFrom("random@example.com"));
|
||||
assertThat(unlisted.status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(unlisted.reasons()).containsExactly("That from address is not set up for provider relay");
|
||||
assertThat(EmailService.send(request("relay").withFrom("old@example.com")).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(EmailService.send(request("relay").withFrom("sender@example.com")).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(EmailService.send(request("relay").withFrom("not an address")).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Without rows, behavior is unchanged: any from text, and the provider's from address and name.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallsBackToProviderFromAddress() throws Exception
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", relay.getValue("id")).withValue("fromName", "Relay Team"));
|
||||
EmailSendResult plain = EmailService.send(request("relay"));
|
||||
QRecord stored = EmailStore.get(EmailTables.MESSAGE, plain.messageId(), null);
|
||||
assertThat(stored.getValueString("fromAddress")).contains("sender@example.com").contains("Relay Team");
|
||||
assertThat(stored.getValue("fromAddressId")).isNull();
|
||||
EmailSendResult explicit = EmailService.send(request("relay").withFrom("anyone@example.org"));
|
||||
assertThat(explicit.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(EmailStore.get(EmailTables.MESSAGE, explicit.messageId(), null).getValueString("fromAddress")).isEqualTo("anyone@example.org");
|
||||
H2Support.insert(EmailTables.SENDER, row(other, "help@example.com"));
|
||||
EmailSendResult unaffected = EmailService.send(request("relay").withFrom("anyone@example.org"));
|
||||
assertThat(unaffected.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An enabled default row wins over the provider's own from address; a disabled one does not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultRowWins() throws Exception
|
||||
{
|
||||
QRecord preferred = H2Support.insert(EmailTables.SENDER, row(relay, "team@example.com").withValue("displayName", "The Team").withValue("isDefault", true));
|
||||
EmailSendResult result = EmailService.send(request("relay"));
|
||||
QRecord stored = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
|
||||
assertThat(stored.getValueString("fromAddress")).contains("team@example.com").contains("The Team");
|
||||
assertThat(stored.getValueInteger("fromAddressId")).isEqualTo(preferred.getValueInteger("id"));
|
||||
SystemWrites.updateOne(EmailTables.SENDER, new QRecord().withValue("id", preferred.getValue("id")).withValue("enabled", false));
|
||||
QRecord back = EmailStore.get(EmailTables.MESSAGE, EmailService.send(request("relay")).messageId(), null);
|
||||
assertThat(back.getValueString("fromAddress")).isEqualTo("sender@example.com");
|
||||
assertThat(back.getValue("fromAddressId")).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A resend keeps the original sender row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resendKeepsSenderRow() throws Exception
|
||||
{
|
||||
QRecord mine = H2Support.insert(EmailTables.SENDER, row(relay, "sales@example.com"));
|
||||
EmailSendResult sent = EmailService.send(request("relay").withFromAddressId(mine.getValueInteger("id")));
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", sent.messageId()).withValue("status", "DEAD_LETTER"));
|
||||
EmailSendResult again = EmailService.resend(sent.messageId());
|
||||
assertThat(again.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(EmailStore.get(EmailTables.MESSAGE, again.messageId(), null).getValueInteger("fromAddressId")).isEqualTo(mine.getValueInteger("id"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The templated and ad hoc send processes accept providerId and fromAddressId.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendProcessAcceptsProviderAndFromAddressIds() throws Exception
|
||||
{
|
||||
QRecord mine = H2Support.insert(EmailTables.SENDER, row(relay, "sales@example.com"));
|
||||
QRecord theirs = H2Support.insert(EmailTables.SENDER, row(other, "help@example.com"));
|
||||
Map<String, Serializable> values = Map.of("to", "person@example.net", "subject", "Hi", "textBody", "Body", "providerId", relay.getValueInteger("id"), "fromAddressId", mine.getValueInteger("id"));
|
||||
var input = new RunProcessInput();
|
||||
input.setProcessName(EmailProcesses.SEND_AD_HOC);
|
||||
values.forEach(input::addValue);
|
||||
assertThat(new RunProcessAction().execute(input).getValues().get("status")).isEqualTo("QUEUED");
|
||||
var wrong = new RunProcessInput();
|
||||
wrong.setProcessName(EmailProcesses.SEND_AD_HOC);
|
||||
values.forEach(wrong::addValue);
|
||||
wrong.addValue("fromAddressId", theirs.getValueInteger("id"));
|
||||
assertThat(new RunProcessAction().execute(wrong).getValues().get("status")).isEqualTo("REFUSED");
|
||||
var named = new RunProcessInput();
|
||||
named.setProcessName(EmailProcesses.SEND_AD_HOC);
|
||||
values.forEach(named::addValue);
|
||||
named.addValue("providerName", "relay");
|
||||
assertThat(new RunProcessAction().execute(named).getValues().get("status")).isEqualTo("QUEUED");
|
||||
var mismatch = new RunProcessInput();
|
||||
mismatch.setProcessName(EmailProcesses.SEND_AD_HOC);
|
||||
values.forEach(mismatch::addValue);
|
||||
mismatch.addValue("providerName", "other");
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(mismatch)).hasMessageContaining("providerId does not match");
|
||||
var missing = new RunProcessInput();
|
||||
missing.setProcessName(EmailProcesses.SEND_AD_HOC);
|
||||
values.forEach(missing::addValue);
|
||||
missing.addValue("providerId", 4242);
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(missing)).hasMessageContaining("providerId does not match");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Test Email takes an optional from address that must belong to the provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendTestUsesFromAddress() throws Exception
|
||||
{
|
||||
QRecord mine = H2Support.insert(EmailTables.SENDER, row(relay, "sales@example.com"));
|
||||
QRecord theirs = H2Support.insert(EmailTables.SENDER, row(other, "help@example.com"));
|
||||
var wrong = ProcessRunner.runWithValues(EmailProcesses.SEND_TEST, Map.of("toAddress", "test@example.com", "fromAddressId", theirs.getValueInteger("id")), relay.getValue("id"));
|
||||
assertThat(wrong.result()).anyMatch(s -> s.contains("REFUSED"));
|
||||
var ok = ProcessRunner.runWithValues(EmailProcesses.SEND_TEST, Map.of("toAddress", "test@example.com", "fromAddressId", mine.getValueInteger("id")), relay.getValue("id"));
|
||||
assertThat(ok.result()).anyMatch(s -> s.contains("FAILED") || s.contains("SENT") || s.contains("RETRY") || s.contains("QUEUED"));
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).anyMatch(m -> mine.getValue("id").equals(m.getValue("fromAddressId")));
|
||||
}
|
||||
|
||||
// ----- contingent possible-value pairs -----
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert the filter on a field and that a possible-value search with the parent
|
||||
** value returns only that parent's rows. The search runs through core's action.
|
||||
*******************************************************************************/
|
||||
private static void assertContingent(QFieldMetaData field, String inputName, Serializable parentValue, List<Serializable> expected, List<Serializable> excluded) throws Exception
|
||||
{
|
||||
assertThat(field.getPossibleValueSourceFilter()).isNotNull();
|
||||
var filter = field.getPossibleValueSourceFilter().clone();
|
||||
assertThat(filter.toString()).contains("${input." + inputName + "}");
|
||||
filter.interpretValues(Map.of("input", Map.of(inputName, parentValue), "processValues", Map.<String, Serializable>of()), PossibleValueSearchFilterUseCase.FORM);
|
||||
var found = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter));
|
||||
List<String> ids = found.getResults().stream().map(v -> v.getId().toString()).toList();
|
||||
assertThat(ids).containsAll(expected.stream().map(String::valueOf).toList());
|
||||
assertThat(ids).doesNotContainAnyElementsOf(excluded.stream().map(String::valueOf).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One row each for the two providers.
|
||||
*******************************************************************************/
|
||||
private QRecord[] senders() throws Exception
|
||||
{
|
||||
return (new QRecord[] { H2Support.insert(EmailTables.SENDER, row(relay, "a@example.com")), H2Support.insert(EmailTables.SENDER, row(other, "b@example.com")) });
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Templated send: fromAddressId follows providerId.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void templatedSendFromAddressFollowsProvider() throws Exception
|
||||
{
|
||||
QRecord[] rows = senders();
|
||||
QFieldMetaData field = QContext.getQInstance().getProcess(EmailProcesses.SEND_TEMPLATED).getBackendStep("send").getInputMetaData().getFieldThrowing("fromAddressId");
|
||||
assertThat(field.getPossibleValueSourceName()).isEqualTo(EmailTables.SENDER);
|
||||
assertContingent(field, "providerId", relay.getValueInteger("id"), List.of(rows[0].getValueInteger("id")), List.of(rows[1].getValueInteger("id")));
|
||||
assertContingent(field, "providerId", other.getValueInteger("id"), List.of(rows[1].getValueInteger("id")), List.of(rows[0].getValueInteger("id")));
|
||||
assertThat(QContext.getQInstance().getProcess(EmailProcesses.SEND_TEMPLATED).getBackendStep("send").getInputMetaData().getFieldThrowing("providerId").getPossibleValueSourceName()).isEqualTo(EmailTables.PROVIDER);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ad hoc send: fromAddressId follows providerId.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adHocSendFromAddressFollowsProvider() throws Exception
|
||||
{
|
||||
QRecord[] rows = senders();
|
||||
QFieldMetaData field = QContext.getQInstance().getProcess(EmailProcesses.SEND_AD_HOC).getBackendStep("send").getInputMetaData().getFieldThrowing("fromAddressId");
|
||||
assertContingent(field, "providerId", relay.getValueInteger("id"), List.of(rows[0].getValueInteger("id")), List.of(rows[1].getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Test Email: fromAddressId follows the provider being acted on, which a
|
||||
** record action's form only gets from the prepare step's process value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendTestFromAddressFollowsActedOnProvider() throws Exception
|
||||
{
|
||||
QRecord[] rows = senders();
|
||||
QFieldMetaData field = QContext.getQInstance().getProcess(EmailProcesses.SEND_TEST).getFrontendStep("input").getFormFields().stream()
|
||||
.filter(f -> "fromAddressId".equals(f.getName())).findFirst().orElseThrow();
|
||||
assertContingent(field, "id", relay.getValueInteger("id"), List.of(rows[0].getValueInteger("id")), List.of(rows[1].getValueInteger("id")));
|
||||
var input = new RunProcessInput();
|
||||
input.setProcessName(EmailProcesses.SEND_TEST);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue("recordIds", String.valueOf(other.getValue("id")));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
var seeded = new RunProcessAction().execute(input).getValues().get(EmailProcesses.PROVIDER_FILTER_VALUE);
|
||||
assertThat(seeded).isEqualTo(other.getValue("id"));
|
||||
var filter = field.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", Map.<String, Serializable>of(), "processValues", Map.of(EmailProcesses.PROVIDER_FILTER_VALUE, (Serializable) seeded)), PossibleValueSearchFilterUseCase.FORM);
|
||||
var found = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(EmailTables.SENDER).withDefaultQueryFilter(filter));
|
||||
assertThat(found.getResults().stream().map(v -> v.getId().toString()).toList()).containsExactly(rows[1].getValueString("id"));
|
||||
var several = new RunProcessInput();
|
||||
several.setProcessName(EmailProcesses.SEND_TEST);
|
||||
several.addValue("recordsParam", "recordIds");
|
||||
several.addValue("recordIds", "1,2");
|
||||
several.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
assertThat(new RunProcessAction().execute(several).getValues()).doesNotContainKey(EmailProcesses.PROVIDER_FILTER_VALUE);
|
||||
var none = new RunProcessInput();
|
||||
none.setProcessName(EmailProcesses.SEND_TEST);
|
||||
none.addValue("recordsParam", "recordIds");
|
||||
none.addValue("recordIds", "x");
|
||||
none.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
assertThat(new RunProcessAction().execute(none).getValues()).doesNotContainKey(EmailProcesses.PROVIDER_FILTER_VALUE);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Message: fromAddressId follows providerId, and is read-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void messageFromAddressFollowsProvider() throws Exception
|
||||
{
|
||||
QRecord[] rows = senders();
|
||||
QFieldMetaData field = QContext.getQInstance().getTable(EmailTables.MESSAGE).getField("fromAddressId");
|
||||
assertThat(field.getIsEditable()).isFalse();
|
||||
assertContingent(field, "providerId", relay.getValueInteger("id"), List.of(rows[0].getValueInteger("id")), List.of(rows[1].getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Attempt and event: the message follows the provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void attemptAndEventMessageFollowProvider() throws Exception
|
||||
{
|
||||
QRecord fromRelay = EmailStore.get(EmailTables.MESSAGE, EmailService.send(request("relay")).messageId(), null);
|
||||
QRecord fromOther = EmailStore.get(EmailTables.MESSAGE, EmailService.send(request("other")).messageId(), null);
|
||||
for(String table : List.of(EmailTables.ATTEMPT, EmailTables.EVENT))
|
||||
{
|
||||
QFieldMetaData field = QContext.getQInstance().getTable(table).getField("messageId");
|
||||
assertContingent(field, "providerId", relay.getValueInteger("id"), List.of(fromRelay.getValueInteger("id")), List.of(fromOther.getValueInteger("id")));
|
||||
assertContingent(field, "providerId", other.getValueInteger("id"), List.of(fromOther.getValueInteger("id")), List.of(fromRelay.getValueInteger("id")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppression: the source event follows the source message.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void suppressionSourceEventFollowsSourceMessage() throws Exception
|
||||
{
|
||||
QRecord first = EmailStore.get(EmailTables.MESSAGE, EmailService.send(request("relay")).messageId(), null);
|
||||
QRecord second = EmailStore.get(EmailTables.MESSAGE, EmailService.send(request("other")).messageId(), null);
|
||||
QRecord e1 = SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", first.getValue("id")).withValue("eventType", "BOUNCED").withValue("eventKey", "k1"));
|
||||
QRecord e2 = SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", second.getValue("id")).withValue("eventType", "BOUNCED").withValue("eventKey", "k2"));
|
||||
QFieldMetaData field = QContext.getQInstance().getTable(EmailTables.SUPPRESSION).getField("sourceEventId");
|
||||
assertContingent(field, "sourceMessageId", first.getValueInteger("id"), List.of(e1.getValueInteger("id")), List.of(e2.getValueInteger("id")));
|
||||
assertContingent(field, "sourceMessageId", second.getValueInteger("id"), List.of(e2.getValueInteger("id")), List.of(e1.getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fields without a parent scope stay unfiltered.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void independentRelationshipsStayUnfiltered() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(instance.getTable(EmailTables.TEMPLATE).getField("brandId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.MESSAGE).getField("brandId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.MESSAGE).getField("providerId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.MESSAGE).getField("templateId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.VERSION).getField("templateId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.INBOX).getField("providerId").getPossibleValueSourceFilter()).isNull();
|
||||
assertThat(instance.getTable(EmailTables.SENDER).getField("providerId").getPossibleValueSourceFilter()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
import com.kingsrook.qbits.email.app.EmailHome;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus.Item;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus.State;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.template.EmailBuiltIns;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card follows the real configuration (the same provider and brand
|
||||
** resolution the sender uses), the home creates the built-in template, and the
|
||||
** template's owner qbit now decides who a message names as its sender.
|
||||
*******************************************************************************/
|
||||
class EmailSetupTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Item item(String label) throws Exception
|
||||
{
|
||||
return (EmailSetupStatus.load().stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing configured: the provider is the one thing that must be added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void freshInstallNeedsAProvider() throws Exception
|
||||
{
|
||||
assertThat(item("Provider").state()).isEqualTo(State.TODO);
|
||||
assertThat(item("Brands").state()).isEqualTo(State.DEFAULT);
|
||||
assertThat(item("Sender addresses").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item("Marketing email").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item("Scheduler").state()).isEqualTo(State.TODO);
|
||||
assertThat(EmailSetupStatus.load()).allMatch(i -> i.tableName() == null || i.tableName().startsWith("email_"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuring a part moves its line, using the sender's own resolution.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuringMovesTheState() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
assertThat(item("Provider").state()).isEqualTo(State.OK);
|
||||
assertThat(item("Delivery tracking").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL).ready()).isTrue();
|
||||
|
||||
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "acme").withValue("label", "Acme").withValue("isDefault", true));
|
||||
assertThat(item("Brands").state()).isEqualTo(State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home shows the card to admins and creates the built-in template.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeShowsTheCardAndCreatesTheBuiltInTemplate() throws Exception
|
||||
{
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).isEmpty();
|
||||
String html = EmailHome.overview().getHtml();
|
||||
assertThat(html).contains("Setup").contains("Needs setup").contains("href=\"/app/email_provider\"").contains("Next step");
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("name")).isEqualTo("email_secure_message");
|
||||
assertThat(row.getValueString("ownerQbit")).isEqualTo(EmailBuiltIns.OWNER);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registering qbit becomes the message's sender unless the request names one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theTemplatesOwnerQbitIsTheDefaultSender() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("Ready").withBody("Hello").withOwnerQbit("orders"));
|
||||
var plain = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-1"));
|
||||
var named = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-2").withSender("billing", "invoices"));
|
||||
List<QRecord> rows = H2Support.all(EmailTables.MESSAGE);
|
||||
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(plain.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("orders"));
|
||||
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(named.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("billing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated
|
||||
** and the qbit should be fully using them"). For every email_* table:
|
||||
** - runtime code (not just metadata, the app or the home) reads or writes it;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here with
|
||||
** why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class EmailTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "email");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("email_provider", "host connection config: a server and the NAMES of its secret variables (kof22 seeds its provider)"),
|
||||
Map.entry("email_provider_sender_address", "optional: a provider's own from address is used without rows"),
|
||||
Map.entry("email_message", "runtime: every queued email"),
|
||||
Map.entry("email_message_attempt", "runtime: every delivery attempt"),
|
||||
Map.entry("email_template", "created at runtime: the secure-message built-in on first use (EmailBuiltIns), others by the qbits that register them (EmailApi.ensureTemplate)"),
|
||||
Map.entry("email_template_version", "created with a template, and by New Version"),
|
||||
Map.entry("email_suppression", "runtime: bounces, complaints, unsubscribes and manual blocks"),
|
||||
Map.entry("email_webhook_inbox", "runtime: verified provider callbacks"),
|
||||
Map.entry("email_event", "runtime: delivery tracking events"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
|
||||
.map(EmailTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
String constant = constantFor(table);
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> source.contains("EmailTables." + constant)))
|
||||
.as(table + " (EmailTables." + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
String changelog = EmailDefaultsChangelogTest.changelogText();
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
boolean seeded = EmailDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
HelpBundleAssertions.forInstance(instance).bundles("help/qbit-email.yaml")
|
||||
.owning(target -> target.name().startsWith("email"))
|
||||
.assertComplete();
|
||||
var entries = HelpBundles.of(instance).resolveWinningEntries();
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
var help = entries.get(new HelpBundles.EntryKey("table:" + table + ";slot:help", "ALL_SCREENS"));
|
||||
assertThat(help).as(table + " table help").isNotNull();
|
||||
assertThat(help.entry().getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(entries).containsKey(new HelpBundles.EntryKey("app:email;slot:help", "ALL_SCREENS"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The EmailTables constant name for a table name.
|
||||
*******************************************************************************/
|
||||
private static String constantFor(String table) throws Exception
|
||||
{
|
||||
for(var field : EmailTables.class.getDeclaredFields())
|
||||
{
|
||||
if(field.getType() == String.class && Modifier.isStatic(field.getModifiers()) && table.equals(field.get(null)))
|
||||
{
|
||||
return (field.getName());
|
||||
}
|
||||
}
|
||||
throw (new IllegalStateException("No constant for " + table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -148,7 +148,10 @@ class EmailTemplatesTest
|
||||
assertThat(((RawHTML) preview.getWidgetData()).getHtml()).contains("<p>Email</p>");
|
||||
assertThat(EmailStore.get(EmailTables.VERSION, version.getValueInteger("id"), null).getValue("compiledHtml")).isNull();
|
||||
EmailVersions.activate(version.getValueInteger("id"));
|
||||
EmailRuntime.config().withMjmlCompiler(source -> { throw new AssertionError("Compiler ran at send time"); });
|
||||
EmailRuntime.config().withMjmlCompiler(source ->
|
||||
{
|
||||
throw new AssertionError("Compiler ran at send time");
|
||||
});
|
||||
var result = EmailService.send(new EmailRequest().withTemplateName("mjml").withLocale("fr").withTo("p@example.com"));
|
||||
assertThat(result.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
QRecord row = EmailStore.get(EmailTables.MESSAGE, result.messageId(), null);
|
||||
@@ -157,6 +160,33 @@ class EmailTemplatesTest
|
||||
assertThat(row.getValueString("subject")).endsWith(result.messageId().toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Template categories and retained resend links enforce the same mailbox boundary.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsubscribeTemplateCategoryAndResend() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withSecretResolver(name -> "test-unsubscribe-key");
|
||||
QRecord version = draft("notice", "MARKDOWN", "[]", "{}", "A notification");
|
||||
SystemWrites.updateOne(EmailTables.TEMPLATE, new QRecord().withValue("id", version.getValue("templateId")).withValue("category", "NOTIFICATION"));
|
||||
EmailVersions.activate(version.getValueInteger("id"));
|
||||
var refused = EmailService.send(new EmailRequest().withTemplateName("notice").withTo("person@example.com").withBcc("other@example.com"));
|
||||
assertThat(refused.status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(refused.reasons()).containsExactly("Notification and marketing email with an unsubscribe link goes to one recipient per message; send one message per recipient.");
|
||||
var queued = EmailService.send(new EmailRequest().withTemplateName("notice").withTo("\"Person, One\" <PERSON@example.com>"));
|
||||
assertThat(queued.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
var resent = EmailService.resend(queued.messageId());
|
||||
assertThat(resent.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, queued.messageId(), null);
|
||||
String url = message.getValueString("unsubscribeUrl");
|
||||
assertThat(EmailStore.get(EmailTables.MESSAGE, resent.messageId(), null).getValueString("unsubscribeUrl")).isEqualTo(url);
|
||||
new com.kingsrook.qbits.email.tracking.EmailUnsubscribe().unsubscribe(url.substring(url.lastIndexOf('/') + 1));
|
||||
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1).allSatisfy(row -> assertThat(row.getValueString("address")).isEqualTo("person@example.com"));
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", queued.messageId()).withValue("ccAddresses", "other@example.com"));
|
||||
EmailRuntime.config().withSecretResolver(name -> null);
|
||||
assertThat(EmailService.resend(queued.messageId()).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Branded rendered templates reach a real SMTP sink with both alternatives.
|
||||
*******************************************************************************/
|
||||
@@ -191,7 +221,7 @@ class EmailTemplatesTest
|
||||
@Test
|
||||
void previewAndHome() throws Exception
|
||||
{
|
||||
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("<style", "overflow:auto");
|
||||
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Setup", "Relay").doesNotContain("Provider health").doesNotContain("<style", "overflow:auto");
|
||||
assertThat(EmailHome.hourly().getSublabel()).contains("No completed attempts");
|
||||
assertThat(EmailHome.suppressions().getSublabel()).contains("No new suppressions");
|
||||
QRecord version = draft("preview", "HTML", "[]", "{}", "<html><body><p>Preview text</p><script>evil()</script><img src=x onerror=evil()></body></html>");
|
||||
@@ -203,7 +233,7 @@ class EmailTemplatesTest
|
||||
EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person <person@example.com>"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("p***@example.com", "/app/email_message/");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("needs email.admin");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "needs email.admin");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -220,8 +250,15 @@ class EmailTemplatesTest
|
||||
SystemWrites.insertOne(EmailTables.ATTEMPT, new QRecord().withValue("messageId", 1).withValue("providerId", 1).withValue("attemptNumber", 1)
|
||||
.withValue("outcome", "SUCCEEDED").withValue("startDate", now.minusSeconds(10)).withValue("endDate", now));
|
||||
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "x@example.com").withValue("scope", "ALL").withValue("reason", "MANUAL"));
|
||||
for(String type : List.of("DELIVERED", "BOUNCED", "COMPLAINED"))
|
||||
{
|
||||
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", type).withValue("eventKey", type)
|
||||
.withValue("occurredDate", "DELIVERED".equals(type) ? now : now.minusSeconds(("BOUNCED".equals(type) ? 2 : 8) * 86400)));
|
||||
}
|
||||
SystemWrites.insertOne(EmailTables.EVENT, new QRecord().withValue("messageId", 1).withValue("eventType", "DELIVERED").withValue("eventKey", "second-delivery")
|
||||
.withValue("occurredDate", now.minusSeconds(60)));
|
||||
var tiles = org.jsoup.Jsoup.parse(EmailHome.overview().getHtml()).select("body > div > div > div");
|
||||
assertThat(tiles).extracting(org.jsoup.nodes.Element::text).containsExactly("1 Sent (24 h)", "1 Delivered (24 h)", "0 Dead letters (24 h)", "1 Bounced (7 d)", "0 Complaints (7 d)", "1 Suppressed addresses");
|
||||
assertThat(tiles).extracting(org.jsoup.nodes.Element::text).containsExactly("1 Sent (24 h)", "2 Delivered (24 h)", "0 Dead letters (24 h)", "1 Bounced (7 d)", "0 Complaints (7 d)", "1 Suppressed addresses");
|
||||
assertThat(EmailHome.hourly().getChartData().getDatasets().getFirst().getData()).contains(1);
|
||||
assertThat(EmailHome.suppressions().getChartData().getDatasets().get(3).getData()).contains(1);
|
||||
QRecord defaultBrand = H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default").withValue("isDefault", true));
|
||||
@@ -261,6 +298,12 @@ class EmailTemplatesTest
|
||||
EmailRuntime.config().withSecretResolver(name -> null);
|
||||
assertThat(com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "address", "NOTIFICATION")).isNull();
|
||||
assertThatThrownBy(() -> com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "address", "MARKETING")).hasMessageContaining("QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
throw new IllegalArgumentException("resolver detail");
|
||||
});
|
||||
assertThatThrownBy(() -> com.kingsrook.qbits.email.render.EmailUnsubscribeLinks.create("1", "person@example.com", "NOTIFICATION"))
|
||||
.hasMessage("Opt-out email needs publicBaseUrl and QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Referential integrity guardrail: docs/ddl/email-postgres.sql, applied to
|
||||
** a real PostgreSQL, has a foreign key for every join and every table
|
||||
** possible-value field between the qbit's tables (or the relation is in
|
||||
** {@link #EXEMPT} with a reason), each with the intended ON DELETE. A new
|
||||
** table or join without its key fails here. The upgrade script is checked
|
||||
** too: it quarantines orphans, adds the same keys, and is idempotent.
|
||||
*******************************************************************************/
|
||||
class ForeignKeyGuardrailTest
|
||||
{
|
||||
private static final Path DDL = Path.of("docs/ddl/email-postgres.sql");
|
||||
private static final Path UPGRADE = Path.of("docs/ddl/email-foreign-keys-upgrade.sql");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// child_table.child_column -> parent_table : ON DELETE (pg confdeltype) //
|
||||
// c = CASCADE, n = SET NULL, r = RESTRICT //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<String, String> EXPECTED = new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("email_provider_sender_address.provider_id -> email_provider", "c"),
|
||||
Map.entry("email_message.provider_id -> email_provider", "r"),
|
||||
Map.entry("email_message.from_address_id -> email_provider_sender_address", "n"),
|
||||
Map.entry("email_message.template_id -> email_template", "n"),
|
||||
Map.entry("email_message.template_version_id -> email_template_version", "n"),
|
||||
Map.entry("email_message.brand_id -> email_brand", "n"),
|
||||
Map.entry("email_message_attempt.message_id -> email_message", "c"),
|
||||
Map.entry("email_message_attempt.provider_id -> email_provider", "r"),
|
||||
Map.entry("email_template.brand_id -> email_brand", "r"),
|
||||
Map.entry("email_template.active_version_id -> email_template_version", "n"),
|
||||
Map.entry("email_template.approved_version_id -> email_template_version", "n"),
|
||||
Map.entry("email_template_version.template_id -> email_template", "r"),
|
||||
Map.entry("email_suppression.source_message_id -> email_message", "n"),
|
||||
Map.entry("email_suppression.source_event_id -> email_event", "n"),
|
||||
Map.entry("email_webhook_inbox.provider_id -> email_provider", "r"),
|
||||
Map.entry("email_event.message_id -> email_message", "c"),
|
||||
Map.entry("email_event.provider_id -> email_provider", "r")));
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// keys for relations that exist only in code (no join or PVS in metadata) //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final List<String> CODE_ONLY = List.of(
|
||||
"email_suppression.source_message_id -> email_message");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// relations that are deliberately not foreign keys, with the reason //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<String, String> EXEMPT = Map.of();
|
||||
|
||||
private static PostgreSQLContainer postgres;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start PostgreSQL once (a missing Docker fails in CI, skips locally).
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void startPostgres()
|
||||
{
|
||||
boolean docker;
|
||||
try
|
||||
{
|
||||
docker = DockerClientFactory.instance().isDockerAvailable();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
docker = false;
|
||||
}
|
||||
if(!docker && System.getenv("CI") != null)
|
||||
{
|
||||
Assertions.fail("Docker is required for the foreign key guardrail in CI");
|
||||
}
|
||||
Assumptions.assumeTrue(docker, "Docker is not available");
|
||||
postgres = new PostgreSQLContainer("postgres:17-alpine").withDatabaseName("email").withUsername("qqq").withPassword("qqq");
|
||||
postgres.start();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop PostgreSQL.
|
||||
*******************************************************************************/
|
||||
@AfterAll
|
||||
static void stopPostgres()
|
||||
{
|
||||
if(postgres != null)
|
||||
{
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A clean schema from the reference DDL.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
try(Connection c = connect(); Statement st = c.createStatement())
|
||||
{
|
||||
st.execute("DROP SCHEMA public CASCADE");
|
||||
st.execute("CREATE SCHEMA public");
|
||||
st.execute(Files.readString(DDL, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear the QContext the metadata setup opened.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every declared relation is a foreign key (or exempt), with the intended
|
||||
** ON DELETE, and every foreign key column leads an index.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyRelationHasAForeignKey() throws Exception
|
||||
{
|
||||
TreeSet<String> declared = declaredRelations(H2Support.setup(config ->
|
||||
{
|
||||
}));
|
||||
Map<String, String> keys = foreignKeys();
|
||||
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String relation : declared)
|
||||
{
|
||||
if(!keys.containsKey(relation) && !EXEMPT.containsKey(relation))
|
||||
{
|
||||
missing.add(relation);
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("relations declared in metadata without a foreign key in " + DDL + " (add one, or exempt it with a reason)").isEmpty();
|
||||
assertThat(declared).as("every expected relation is still declared").containsAll(EXPECTED.keySet().stream().filter(k -> !CODE_ONLY.contains(k)).toList());
|
||||
assertThat(keys).as("foreign keys and their ON DELETE").isEqualTo(EXPECTED);
|
||||
assertThat(unindexedForeignKeys()).as("foreign key columns that lead no index").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The upgrade script adds the same keys to a schema without them, recording
|
||||
** and removing orphans first, and is a no-op the second time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void upgradeQuarantinesOrphansAndIsIdempotent() throws Exception
|
||||
{
|
||||
try(Connection c = connect(); Statement st = c.createStatement())
|
||||
{
|
||||
dropForeignKeys(st);
|
||||
st.execute("""
|
||||
INSERT INTO email_provider (id, name) VALUES (1, 'relay');
|
||||
INSERT INTO email_provider_sender_address (id, provider_id, address) VALUES (1, 1, 'a@example.com'), (2, 99, 'b@example.com');
|
||||
INSERT INTO email_message (id, provider_id, from_address_id, template_id, brand_id) VALUES (1, 1, 1, NULL, NULL), (2, 1, 2, 98, 97);
|
||||
INSERT INTO email_message_attempt (id, message_id, attempt_number, provider_id) VALUES (1, 1, 1, 1), (2, 96, 1, 1);
|
||||
INSERT INTO email_event (id, message_id, provider_id, event_type, event_key) VALUES (1, 1, 1, 'DELIVERED', 'k1'), (2, 95, 1, 'BOUNCED', 'k2');
|
||||
INSERT INTO email_suppression (id, address, scope, source_message_id, source_event_id) VALUES (1, 'x@example.com', 'ALL', 95, 2);
|
||||
INSERT INTO email_webhook_inbox (id, provider_id, kind, external_event_id, status) VALUES (1, 94, 'SES', 'e1', 'PENDING');
|
||||
""");
|
||||
|
||||
st.execute(Files.readString(UPGRADE, StandardCharsets.UTF_8));
|
||||
assertThat(foreignKeys()).isEqualTo(EXPECTED);
|
||||
assertThat(quarantine(st)).containsExactlyInAnyOrder(
|
||||
"email_provider_sender_address.provider_id row 2 parent 99 DELETED",
|
||||
"email_message.from_address_id row 2 parent 2 NULLED",
|
||||
"email_message.template_id row 2 parent 98 NULLED",
|
||||
"email_message.brand_id row 2 parent 97 NULLED",
|
||||
"email_message_attempt.message_id row 2 parent 96 DELETED",
|
||||
"email_event.message_id row 2 parent 95 DELETED",
|
||||
"email_webhook_inbox.provider_id row 1 parent 94 DELETED",
|
||||
"email_suppression.source_message_id row 1 parent 95 NULLED",
|
||||
"email_suppression.source_event_id row 1 parent 2 NULLED");
|
||||
assertThat(count(st, "SELECT COUNT(*) FROM email_suppression")).isEqualTo(1);
|
||||
|
||||
st.execute(Files.readString(UPGRADE, StandardCharsets.UTF_8));
|
||||
assertThat(foreignKeys()).isEqualTo(EXPECTED);
|
||||
assertThat(quarantine(st)).hasSize(9);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Message retention (events, attempts, then the message) keeps the
|
||||
** suppression; the template/version cycle inserts in the qbit's order; a
|
||||
** provider with history, a brand in use and a template with versions
|
||||
** cannot be deleted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deletesFollowTheIntendedRules() throws Exception
|
||||
{
|
||||
try(Connection c = connect(); Statement st = c.createStatement())
|
||||
{
|
||||
st.execute("""
|
||||
INSERT INTO email_provider (id, name) VALUES (1, 'relay'), (2, 'spare');
|
||||
INSERT INTO email_provider_sender_address (provider_id, address) VALUES (2, 'b@example.com');
|
||||
INSERT INTO email_brand (id, name) VALUES (1, 'default');
|
||||
INSERT INTO email_template (id, name, brand_id) VALUES (1, 'welcome', 1);
|
||||
INSERT INTO email_template_version (id, template_id, version_number, locale) VALUES (1, 1, 1, 'en'), (2, 1, 2, 'en');
|
||||
UPDATE email_template SET active_version_id = 1, approved_version_id = 1 WHERE id = 1;
|
||||
INSERT INTO email_message (id, provider_id, template_id, template_version_id, brand_id) VALUES (1, 1, 1, 1, 1);
|
||||
INSERT INTO email_message_attempt (message_id, attempt_number, provider_id) VALUES (1, 1, 1);
|
||||
INSERT INTO email_event (id, message_id, provider_id, event_type, event_key) VALUES (1, 1, 1, 'BOUNCED', 'k1');
|
||||
INSERT INTO email_suppression (address, scope, source_message_id, source_event_id) VALUES ('x@example.com', 'ALL', 1, 1);
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> st.execute("DELETE FROM email_provider WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_");
|
||||
assertThatThrownBy(() -> st.execute("DELETE FROM email_template WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_template_version_template_id");
|
||||
assertThatThrownBy(() -> st.execute("DELETE FROM email_brand WHERE id = 1")).isInstanceOf(SQLException.class).hasMessageContaining("fk_email_template_brand_id");
|
||||
|
||||
st.execute("DELETE FROM email_template_version WHERE id = 2");
|
||||
st.execute("DELETE FROM email_provider WHERE id = 2");
|
||||
assertThat(count(st, "SELECT COUNT(*) FROM email_provider_sender_address")).isZero();
|
||||
|
||||
st.execute("DELETE FROM email_event WHERE message_id = 1");
|
||||
st.execute("DELETE FROM email_message_attempt WHERE message_id = 1");
|
||||
st.execute("DELETE FROM email_message WHERE id = 1");
|
||||
assertThat(count(st, "SELECT COUNT(*) FROM email_suppression WHERE source_message_id IS NULL AND source_event_id IS NULL")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** child_table.child_column -> parent_table for every join and table PVS
|
||||
** field between two of the qbit's tables.
|
||||
*******************************************************************************/
|
||||
static TreeSet<String> declaredRelations(QInstance instance)
|
||||
{
|
||||
TreeSet<String> relations = new TreeSet<>();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(!isQbitTable(table))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
QPossibleValueSource pvs = field.getPossibleValueSourceName() == null ? null : instance.getPossibleValueSource(field.getPossibleValueSourceName());
|
||||
if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && isQbitTable(instance.getTable(pvs.getTableName())))
|
||||
{
|
||||
relations.add(relation(table, field, instance.getTable(pvs.getTableName())));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QJoinMetaData join : instance.getJoins().values())
|
||||
{
|
||||
QTableMetaData left = instance.getTable(join.getLeftTable());
|
||||
QTableMetaData right = instance.getTable(join.getRightTable());
|
||||
if(!isQbitTable(left) || !isQbitTable(right))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(JoinOn on : join.getJoinOns())
|
||||
{
|
||||
if(on.getLeftField().equals(left.getPrimaryKeyField()))
|
||||
{
|
||||
relations.add(relation(right, right.getField(on.getRightField()), left));
|
||||
}
|
||||
else if(on.getRightField().equals(right.getPrimaryKeyField()))
|
||||
{
|
||||
relations.add(relation(left, left.getField(on.getLeftField()), right));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (relations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a table is one of the qbit's.
|
||||
*******************************************************************************/
|
||||
private static boolean isQbitTable(QTableMetaData table)
|
||||
{
|
||||
return (table != null && table.getName().startsWith("email_"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A relation key.
|
||||
*******************************************************************************/
|
||||
private static String relation(QTableMetaData child, QFieldMetaData field, QTableMetaData parent)
|
||||
{
|
||||
String column = field.getBackendName() != null ? field.getBackendName() : field.getName();
|
||||
return (child.getName() + "." + column + " -> " + parent.getName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The single-column foreign keys in the database, with their ON DELETE.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> foreignKeys() throws SQLException
|
||||
{
|
||||
Map<String, String> keys = new TreeMap<>();
|
||||
try(Connection c = connect(); Statement st = c.createStatement(); ResultSet rs = st.executeQuery("""
|
||||
SELECT c.conrelid::regclass::text AS child, a.attname AS col, c.confrelid::regclass::text AS parent, c.confdeltype::text AS rule
|
||||
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND cardinality(c.conkey) = 1
|
||||
"""))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
keys.put(rs.getString("child") + "." + rs.getString("col") + " -> " + rs.getString("parent"), rs.getString("rule"));
|
||||
}
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Foreign key columns that are not the first column of any index.
|
||||
*******************************************************************************/
|
||||
private static List<String> unindexedForeignKeys() throws SQLException
|
||||
{
|
||||
List<String> list = new ArrayList<>();
|
||||
try(Connection c = connect(); Statement st = c.createStatement(); ResultSet rs = st.executeQuery("""
|
||||
SELECT c.conrelid::regclass::text || '.' || a.attname AS col
|
||||
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1])
|
||||
"""))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
list.add(rs.getString("col"));
|
||||
}
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop every foreign key (a schema as it was before they were declared).
|
||||
*******************************************************************************/
|
||||
private static void dropForeignKeys(Statement st) throws SQLException
|
||||
{
|
||||
List<String> drops = new ArrayList<>();
|
||||
try(ResultSet rs = st.executeQuery("SELECT conrelid::regclass::text AS t, conname FROM pg_constraint WHERE contype = 'f'"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
drops.add("ALTER TABLE " + rs.getString("t") + " DROP CONSTRAINT " + rs.getString("conname"));
|
||||
}
|
||||
}
|
||||
for(String drop : drops)
|
||||
{
|
||||
st.execute(drop);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The quarantine rows, as text.
|
||||
*******************************************************************************/
|
||||
private static List<String> quarantine(Statement st) throws SQLException
|
||||
{
|
||||
List<String> rows = new ArrayList<>();
|
||||
try(ResultSet rs = st.executeQuery("SELECT table_name, column_name, row_id, missing_parent_id, action FROM qqq_fk_quarantine ORDER BY id"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
rows.add(rs.getString(1) + "." + rs.getString(2) + " row " + rs.getString(3) + " parent " + rs.getString(4) + " " + rs.getString(5));
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A count.
|
||||
*******************************************************************************/
|
||||
private static int count(Statement st, String sql) throws SQLException
|
||||
{
|
||||
try(ResultSet rs = st.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
return (rs.getInt(1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A connection.
|
||||
*******************************************************************************/
|
||||
private static Connection connect() throws SQLException
|
||||
{
|
||||
return (DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public final class H2Support
|
||||
*******************************************************************************/
|
||||
public static QInstance instance(RDBMSBackendMetaData backend, Consumer<EmailQBitConfig> tweak)
|
||||
throws Exception
|
||||
{
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(
|
||||
new QAuthenticationMetaData()
|
||||
|
||||
@@ -81,7 +81,17 @@ class PhiCanaryEgressTest
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
canary = "CANARY-PHI-" + UUID.randomUUID();
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the random part is mapped from hex to the letters g-v: a five-char //
|
||||
// window of a hex canary could match any hex digest (body hashes, //
|
||||
// Message-IDs) by chance; these windows can only match real leakage //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
StringBuilder random = new StringBuilder();
|
||||
for(char c : UUID.randomUUID().toString().replace("-", "").toCharArray())
|
||||
{
|
||||
random.append((char) ('g' + Character.digit(c, 16)));
|
||||
}
|
||||
canary = "CANARY-PHI-" + random;
|
||||
H2Support.setup(c -> c.withMaxAttempts(2).withSecretResolver(PhiCanaryEgressTest::secret)
|
||||
.withClassificationOverrides(Map.of("test_patient.name", EmailClassifications.Level.PHI)));
|
||||
H2Support.provider(25);
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class ProcessRunner
|
||||
public static Lines runWithValues(
|
||||
String process, java.util.Map<String, Serializable> values, Serializable... ids)
|
||||
throws Exception
|
||||
{
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(process);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.template.EmailVersions;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host template grants never imply email administration or bypass record locks.
|
||||
*******************************************************************************/
|
||||
class SharedTemplateAccessTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use real QQQ permission and database actions with explicit host opt-in.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(config -> config.withTemplatePermissionBaseName("crm.manage"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear request-local state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Template grants do not expose providers, messages or PHI approval.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void narrowPermissionsAndAdditionalEditGrant() throws Exception
|
||||
{
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_legacy_1").withLabel("Legacy")
|
||||
.withKind("CRM_LEGACY").withOwnerQbit("crm").withEditPermissionName("crm.manage.write")
|
||||
.withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("Hello {{contact.firstName}}").withBody("body"));
|
||||
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "crm_legacy_1")).getFirst();
|
||||
Integer id = template.getValueInteger("id");
|
||||
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
|
||||
var request = new EmailApiRequest().withCategory(EmailApiCategory.MARKETING).withVariable("contact.firstName", "Ada");
|
||||
assertThat(EmailApi.renderTemplate(id, request).subject()).isEqualTo("Hello Ada");
|
||||
assertThatThrownBy(() -> EmailVersions.requireEdit(template, false)).hasMessageContaining("email.manage");
|
||||
EmailPermissionChecker checker = new EmailPermissionChecker();
|
||||
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new QueryInput(EmailTables.PROVIDER), QContext.getQInstance().getTable(EmailTables.PROVIDER))).hasMessageContaining("email.admin");
|
||||
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new QueryInput(EmailTables.MESSAGE), QContext.getQInstance().getTable(EmailTables.MESSAGE))).hasMessageContaining("email.view");
|
||||
QContext.getQSession().setPermissions(Set.of("crm.manage.read", "crm.manage.write"));
|
||||
EmailVersions.requireEdit(template, false);
|
||||
checker.checkPermissionsThrowing(new UpdateInput(EmailTables.TEMPLATE), QContext.getQInstance().getTable(EmailTables.TEMPLATE));
|
||||
assertThatThrownBy(() -> EmailVersions.requireEdit(template, true)).hasMessageContaining("email.admin");
|
||||
template.setValue("editPermissionName", "another.owner.write");
|
||||
assertThatThrownBy(() -> EmailVersions.requireEdit(template, false)).hasMessageContaining("another.owner.write");
|
||||
assertThatThrownBy(() -> checker.checkPermissionsThrowing(new UpdateInput(EmailTables.BRAND), QContext.getQInstance().getTable(EmailTables.BRAND))).hasMessageContaining("email.manage");
|
||||
assertThat(EmailProcesses.permissionFor(EmailProcesses.APPROVE_PHI)).isEqualTo("email.admin");
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThatThrownBy(() -> EmailApi.renderTemplate(id, request)).hasMessageContaining("email.view");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A permitted table read still cannot render another tenant's template.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void renderHonorsRecordLocks() throws Exception
|
||||
{
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_locked").withLabel("Locked")
|
||||
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("subject").withBody("body"));
|
||||
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "crm_locked")).getFirst();
|
||||
QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("templateAccess"));
|
||||
QContext.getQInstance().getTable(EmailTables.TEMPLATE).withRecordSecurityLock(new RecordSecurityLock().withFieldName("id").withSecurityKeyType("templateAccess"));
|
||||
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
|
||||
assertThatThrownBy(() -> EmailApi.renderTemplate(template.getValueInteger("id"), new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL)))
|
||||
.hasMessageContaining("unavailable");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native templates retain their category and typed HTML; approval is not exportable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nativePolicyAndTypedContent() throws Exception
|
||||
{
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("native_html").withLabel("Native")
|
||||
.withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("subject").withBody("<p>body</p>"));
|
||||
var template = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "native_html")).getFirst();
|
||||
Integer id = template.getValueInteger("id");
|
||||
QContext.getQSession().setPermissions(Set.of("crm.manage.read"));
|
||||
var rendered = EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL));
|
||||
assertThat(rendered.htmlBody()).contains("<p>body</p>");
|
||||
assertThat(rendered.markdownBody()).isNull();
|
||||
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.MARKETING))).hasMessageContaining("category cannot be overridden");
|
||||
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("override"))).hasMessageContaining("cannot be overridden");
|
||||
EmailStore.update(EmailTables.TEMPLATE, new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", id).withValue("phiApproved", true), null);
|
||||
assertThatThrownBy(() -> EmailApi.renderTemplate(id, new EmailApiRequest().withCategory(EmailApiCategory.TRANSACTIONAL))).hasMessageContaining("protected template send path");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility content still uses the real outbox and stable template provenance.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void legacyOutboxCategoriesAndIdempotency() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
EmailRuntime.config().withPublicBaseUrl("https://app.example.test").withSecretResolver(name -> "synthetic-unsubscribe-key");
|
||||
H2Support.insert(EmailTables.BRAND, new com.kingsrook.qqq.backend.core.model.data.QRecord()
|
||||
.withValue("name", "postal").withValue("isDefault", true).withValue("postalAddress", "123 Test Street"));
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("crm_delivery").withLabel("Delivery")
|
||||
.withKind("CRM_LEGACY").withOwnerQbit("crm").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("Hi {{contact.firstName}}").withBody("<b>fallback</b>").withTextBody("Hello {{contact.firstName}}"));
|
||||
for(EmailApiCategory category : java.util.List.of(EmailApiCategory.TRANSACTIONAL, EmailApiCategory.MARKETING))
|
||||
{
|
||||
String key = "crm:test:" + category;
|
||||
var request = new EmailApiRequest().withTo("recipient@example.test").withTemplateName("crm_delivery")
|
||||
.withCategory(category).withVariable("contact.firstName", "{{unknown}} ").withIdempotencyKey(key);
|
||||
var first = EmailApi.sendOnce(request, key);
|
||||
if(first.outcome() == com.kingsrook.qbits.email.api.EmailApiEffectOutcome.REFUSED)
|
||||
{
|
||||
throw (new AssertionError(EmailApi.send(request).toString()));
|
||||
}
|
||||
assertThat(EmailApi.sendOnce(request, key).message().id()).isEqualTo(first.message().id());
|
||||
var row = SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("id", first.message().id())).getFirst();
|
||||
assertThat(row.getValueString("category")).isEqualTo(category.name());
|
||||
assertThat(row.getValueString("subject")).isEqualTo("Hi {{unknown}} ");
|
||||
assertThat(row.getValue("templateId")).isNotNull();
|
||||
assertThat(row.getValue("templateVersionId")).isNotNull();
|
||||
assertThat(row.getValueString("variablesSnapshot")).isEqualTo("{}");
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Final subjects use the actual message ID; replay never exports another envelope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void checkedSubjectAndReplayBinding() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("native_history").withLabel("History")
|
||||
.withKind("HTML").withCategory(EmailApiCategory.TRANSACTIONAL).withSubject("Message {{message.id}}").withBody("<p>body</p>"));
|
||||
Integer id = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("name", "native_history")).getFirst().getValueInteger("id");
|
||||
var request = new EmailApiRequest().withTo("recipient@example.test").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSender("crm", "manual").withLinkedRecord("crm_contact", 1);
|
||||
request.withTo(java.util.List.of("one@example.test,two@example.test"));
|
||||
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:invalid")).hasMessageContaining("exactly one mailbox");
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
request.withTo(java.util.List.of(" recipient@example.test "));
|
||||
var first = EmailApi.sendTemplateOnce(id, request, "crm:checked");
|
||||
assertThat(first.subject()).isEqualTo("Message " + first.effect().message().id());
|
||||
assertThat(EmailApi.sendTemplateOnce(id, request, "crm:checked").subject()).isEqualTo(first.subject());
|
||||
request.withTo(java.util.List.of("other@example.test"));
|
||||
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
|
||||
request.withTo(java.util.List.of("recipient@example.test")).withLinkedRecord("crm_contact", 2);
|
||||
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
|
||||
request.withLinkedRecord("crm_contact", 1).withSender("other", "manual");
|
||||
assertThatThrownBy(() -> EmailApi.sendTemplateOnce(id, request, "crm:checked")).hasMessageContaining("envelope does not match");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.lang.reflect.GenericArrayType;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.TypeVariable;
|
||||
import java.lang.reflect.WildcardType;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No ArchUnit dependency: inspect every compiled API class and nested type,
|
||||
** including generic arguments, bounds, arrays, exceptions and inheritance.
|
||||
*******************************************************************************/
|
||||
class EmailApiBoundaryTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Discovery uses production output so new API classes cannot escape review.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void publishedSignaturesUseOnlyApiJdkAndQqqCoreTypes() throws Exception
|
||||
{
|
||||
Path root = Path.of(EmailApi.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
Path packagePath = root.resolve("com/kingsrook/qbits/email/api");
|
||||
int count = 0;
|
||||
try(var files = Files.walk(packagePath))
|
||||
{
|
||||
for(Path path : files.filter(p -> p.toString().endsWith(".class")).toList())
|
||||
{
|
||||
String name = root.relativize(path).toString().replace('\\', '.').replace('/', '.').replaceFirst("\\.class$", "");
|
||||
inspect(Class.forName(name));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertThat(count).isGreaterThanOrEqualTo(11);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prove the guard catches indirect leaks, not just raw return classes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsNestedGenericAndProtectedLeaks() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> inspect(LeakingGeneric.class)).isInstanceOf(AssertionError.class);
|
||||
assertThatThrownBy(() -> inspect(LeakingProtected.class)).isInstanceOf(AssertionError.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructors, fields, inherited methods and protected declarations count.
|
||||
*******************************************************************************/
|
||||
private void inspect(Class<?> apiClass)
|
||||
{
|
||||
Set<Type> seen = new HashSet<>();
|
||||
check(apiClass.getGenericSuperclass(), seen);
|
||||
checkAll(apiClass.getGenericInterfaces(), seen);
|
||||
checkAll(apiClass.getTypeParameters(), seen);
|
||||
for(var field : apiClass.getDeclaredFields())
|
||||
{
|
||||
if(visible(field.getModifiers()))
|
||||
{
|
||||
check(field.getGenericType(), seen);
|
||||
}
|
||||
}
|
||||
for(var constructor : apiClass.getDeclaredConstructors())
|
||||
{
|
||||
if(visible(constructor.getModifiers()))
|
||||
{
|
||||
checkAll(constructor.getGenericParameterTypes(), seen);
|
||||
checkAll(constructor.getGenericExceptionTypes(), seen);
|
||||
checkAll(constructor.getTypeParameters(), seen);
|
||||
}
|
||||
}
|
||||
Set<java.lang.reflect.Method> methods = new HashSet<>(java.util.List.of(apiClass.getDeclaredMethods()));
|
||||
methods.addAll(java.util.List.of(apiClass.getMethods()));
|
||||
for(var method : methods)
|
||||
{
|
||||
if(visible(method.getModifiers()))
|
||||
{
|
||||
check(method.getGenericReturnType(), seen);
|
||||
checkAll(method.getGenericParameterTypes(), seen);
|
||||
checkAll(method.getGenericExceptionTypes(), seen);
|
||||
checkAll(method.getTypeParameters(), seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Public and protected form the published contract.
|
||||
*******************************************************************************/
|
||||
private boolean visible(int modifiers)
|
||||
{
|
||||
return (Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Walk each type without looping on recursive type bounds.
|
||||
*******************************************************************************/
|
||||
private void checkAll(Type[] types, Set<Type> seen)
|
||||
{
|
||||
for(Type type : types)
|
||||
{
|
||||
check(type, seen);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generic containers cannot hide an implementation or third-party type.
|
||||
*******************************************************************************/
|
||||
private void check(Type type, Set<Type> seen)
|
||||
{
|
||||
if(type == null || !seen.add(type))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(type instanceof Class<?> clazz)
|
||||
{
|
||||
if(clazz.isArray())
|
||||
{
|
||||
check(clazz.getComponentType(), seen);
|
||||
}
|
||||
else
|
||||
{
|
||||
String name = clazz.getName();
|
||||
String module = clazz.getModule().getName();
|
||||
boolean jdk = module != null && (module.startsWith("java.") || module.startsWith("jdk."));
|
||||
assertThat(clazz.isPrimitive() || jdk
|
||||
|| name.startsWith("com.kingsrook.qqq.backend.core.") || name.startsWith("com.kingsrook.qbits.email.api."))
|
||||
.as("Published signature type %s", name).isTrue();
|
||||
}
|
||||
}
|
||||
else if(type instanceof ParameterizedType parameterized)
|
||||
{
|
||||
check(parameterized.getRawType(), seen);
|
||||
check(parameterized.getOwnerType(), seen);
|
||||
checkAll(parameterized.getActualTypeArguments(), seen);
|
||||
}
|
||||
else if(type instanceof TypeVariable<?> variable)
|
||||
{
|
||||
checkAll(variable.getBounds(), seen);
|
||||
}
|
||||
else if(type instanceof WildcardType wildcard)
|
||||
{
|
||||
checkAll(wildcard.getUpperBounds(), seen);
|
||||
checkAll(wildcard.getLowerBounds(), seen);
|
||||
}
|
||||
else if(type instanceof GenericArrayType array)
|
||||
{
|
||||
check(array.getGenericComponentType(), seen);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new AssertionError("Unhandled signature type: " + type));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately invalid fixture, outside production class discovery.
|
||||
*******************************************************************************/
|
||||
private static class LeakingGeneric
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A wildcard and array must not disguise an internal request type.
|
||||
*******************************************************************************/
|
||||
public java.util.List<? extends com.kingsrook.qbits.email.outbox.EmailRequest[]> leak()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Protected fields are also part of the API contract.
|
||||
*******************************************************************************/
|
||||
private static class LeakingProtected
|
||||
{
|
||||
protected com.kingsrook.qbits.email.EmailQBitConfig config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailEffects;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.support.EmailApiMapper;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable API mapping and behavior against the real outbox and H2 database.
|
||||
*******************************************************************************/
|
||||
class EmailApiTest
|
||||
{
|
||||
private QInstance instance;
|
||||
private QRecord provider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No environment secrets are used by these tests.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
provider = H2Support.provider(25);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release thread-local context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every request property reaches the internal request with the same value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapsEveryRequestField() throws Exception
|
||||
{
|
||||
Instant after = Instant.parse("2030-01-02T03:04:05Z");
|
||||
EmailApiRequest request = new EmailApiRequest().withTo(new ArrayList<>(List.of("to@example.com"))).withTo("second@example.com")
|
||||
.withCc(new ArrayList<>(List.of("cc@example.com"))).withCc("cc2@example.com")
|
||||
.withBcc(new ArrayList<>(List.of("bcc@example.com"))).withBcc("bcc2@example.com")
|
||||
.withReplyTo("reply@example.com").withFrom("from@example.com").withFromAddressId(8)
|
||||
.withSubject("Subject").withMarkdownBody("Markdown").withHtmlBody("<p>HTML</p>").withTextBody("Text")
|
||||
.withTemplateName("notice").withVariables(new java.util.LinkedHashMap<>(Map.of("number", 7)))
|
||||
.withVariable("label", "value").withRecordVariables(new java.util.LinkedHashMap<>())
|
||||
.withRecordVariable("order", "orders", 42).withCategory(EmailApiCategory.SECURITY)
|
||||
.withProviderName("named").withBrandName("brand").withLocale("fr").withIdempotencyKey("api:42")
|
||||
.withLinkedTableName("old").withLinkedRecordId("old").withLinkedRecord("orders", 42)
|
||||
.withSenderQbit("old").withSenderProcess("old").withSender("orders", "complete").withSendAfter(after)
|
||||
.withTransaction(new QBackendTransaction(), "rdbms");
|
||||
EmailRequest mapped = EmailApiMapper.request(request);
|
||||
for(Method getter : EmailApiRequest.class.getDeclaredMethods())
|
||||
{
|
||||
if(!getter.getName().startsWith("get"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Object value = getter.invoke(request);
|
||||
assertThat(value).as(getter.getName()).isNotNull();
|
||||
if(getter.getName().equals("getCategory"))
|
||||
{
|
||||
assertThat(mapped.getCategory().name()).isEqualTo(request.getCategory().name());
|
||||
}
|
||||
else if(getter.getName().equals("getRecordVariables"))
|
||||
{
|
||||
assertThat(mapped.getRecordVariables()).hasSize(1);
|
||||
assertThat(mapped.getRecordVariables().get("order").table()).isEqualTo("orders");
|
||||
assertThat(mapped.getRecordVariables().get("order").id()).isEqualTo(42);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(EmailRequest.class.getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(value);
|
||||
}
|
||||
}
|
||||
assertThat(mapped.getTo()).containsExactly("to@example.com", "second@example.com");
|
||||
assertThat(mapped.getLinkedRecordId()).isEqualTo("42");
|
||||
assertThat(mapped.getSenderQbit()).isEqualTo("orders");
|
||||
assertThat(new EmailApiRequest().withLinkedRecord("orders", null).getLinkedRecordId()).isNull();
|
||||
assertThat(EmailApiMapper.request(new EmailApiRequest()).getCategory()).isNull();
|
||||
assertThat(EmailApiMapper.request(new EmailApiRequest()).getLocale()).isEqualTo("en");
|
||||
for(EmailApiCategory category : EmailApiCategory.values())
|
||||
{
|
||||
assertThat(EmailApiMapper.request(new EmailApiRequest().withCategory(category)).getCategory()).isEqualTo(EmailCategory.valueOf(category.name()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All statuses and refusal reasons survive mapping without sharing lists.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapsEveryStatus()
|
||||
{
|
||||
for(EmailSendResult.Status status : EmailSendResult.Status.values())
|
||||
{
|
||||
List<String> reasons = new ArrayList<>(List.of("Reason"));
|
||||
EmailApiResult result = EmailApiMapper.result(new EmailSendResult(42, status, reasons));
|
||||
reasons.clear();
|
||||
assertThat(result.messageId()).isEqualTo(42);
|
||||
assertThat(result.status()).isEqualTo(EmailApiStatus.valueOf(status.name()));
|
||||
assertThat(result.reasons()).containsExactly("Reason");
|
||||
assertThatThrownBy(() -> result.reasons().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sends keep validation, idempotency, suppression and permission behavior.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendsThroughExistingService() throws Exception
|
||||
{
|
||||
EmailApiResult queued = EmailApi.send(request("same"));
|
||||
assertThat(queued.status()).isEqualTo(EmailApiStatus.QUEUED);
|
||||
assertThat(EmailApi.send(request("same")).status()).isEqualTo(EmailApiStatus.DUPLICATE);
|
||||
assertThat(EmailApi.send(request("same")).messageId()).isEqualTo(queued.messageId());
|
||||
assertThat(EmailApi.send(null).status()).isEqualTo(EmailApiStatus.REFUSED);
|
||||
assertThat(EmailApi.send(request("bad").withSubject("bad\nsubject")).status()).isEqualTo(EmailApiStatus.REFUSED);
|
||||
SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com")
|
||||
.withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL"));
|
||||
assertThat(EmailApi.send(request("suppressed")).status()).isEqualTo(EmailApiStatus.SUPPRESSED);
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThat(EmailApi.send(request("denied")).reasons()).contains("Sending email needs email.send");
|
||||
assertThat(EmailApi.getMessage(queued.messageId())).isPresent();
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("same", 1)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable handoffs and internal replays agree on the committed row and header.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendsOnceAndReconcilesCurrentMetadata() throws Exception
|
||||
{
|
||||
String key = "job:2:row:email_%";
|
||||
EmailApiRequest request = request(null).withLinkedRecord("orders", 42);
|
||||
EmailApiEffectResult first = EmailApi.sendOnce(request, key);
|
||||
assertThat(request.getIdempotencyKey()).isEqualTo(key);
|
||||
assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE);
|
||||
assertThat(first.message().status()).isEqualTo("QUEUED");
|
||||
assertThat(first.message().idempotencyKey()).isEqualTo(key);
|
||||
assertThat(first.message().rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>");
|
||||
assertThat(EmailApi.sendOnce(request(key), key)).isEqualTo(first);
|
||||
var internal = EmailEffects.send(EmailApiMapper.request(request(null)), key);
|
||||
assertThat(internal.outcome().name()).isEqualTo(first.outcome().name());
|
||||
assertThat(internal.message().id()).isEqualTo(first.message().id());
|
||||
assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId());
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
assertThat(EmailApi.getMessage(first.message().id())).contains(first.message());
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("job:", 5)).containsExactly(first.message());
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id())
|
||||
.withValue("status", "SENT").withValue("providerMessageId", "provider-reference"));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
EmailApiMessage found = EmailApi.getMessageByIdempotencyKey(key).orElseThrow();
|
||||
assertThat(found.id()).isEqualTo(first.message().id());
|
||||
assertThat(found.status()).isEqualTo("SENT");
|
||||
assertThat(found.providerMessageId()).isEqualTo("provider-reference");
|
||||
assertThat(found.rfcMessageId()).isEqualTo(first.message().rfcMessageId());
|
||||
assertThat(found.linkedRecordId()).isEqualTo("42");
|
||||
assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId())).contains(found);
|
||||
for(String missing : new String[] {null, "", " ", "missing", "job:", "job:2:row:email_", "provider-reference"})
|
||||
{
|
||||
assertThat(EmailApi.getMessageByIdempotencyKey(missing)).isEmpty();
|
||||
assertThat(EmailApi.getMessageByRfcMessageId(missing)).isEmpty();
|
||||
}
|
||||
assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId().replace("<", "").replace(">", ""))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid handoffs preserve caller keys and never create an outbox row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendOnceRefusesInvalidRequestsAndKeys() throws Exception
|
||||
{
|
||||
for(String key : new String[] {null, "", " ", "a".repeat(501)})
|
||||
{
|
||||
EmailApiRequest request = request(null);
|
||||
assertThat(EmailApi.sendOnce(request, key)).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
|
||||
assertThat(request.getIdempotencyKey()).isNull();
|
||||
assertThat(EmailEffects.send(EmailApiMapper.request(request), key).outcome().name()).isEqualTo("REFUSED");
|
||||
}
|
||||
EmailApiRequest conflict = request("other");
|
||||
assertThat(EmailApi.sendOnce(conflict, "effect").outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED);
|
||||
assertThat(conflict.getIdempotencyKey()).isEqualTo("other");
|
||||
assertThat(EmailApi.sendOnce(null, "effect")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
|
||||
assertThat(EmailApi.sendOnce(request(null).withSubject(null), "invalid"))
|
||||
.isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
assertThat(EmailApi.sendOnce(request(null), "a".repeat(500)).outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE);
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
assertThat(EmailApi.sendOnce(request(null), "denied")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replaying a suppressed audit row remains refused with the same identity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendOnceRefusesSuppressedDuplicates() throws Exception
|
||||
{
|
||||
SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com")
|
||||
.withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL"));
|
||||
EmailApiEffectResult first = EmailApi.sendOnce(request(null), "suppressed");
|
||||
assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED);
|
||||
assertThat(first.message().status()).isEqualTo("SUPPRESSED");
|
||||
assertThat(first.message().rfcMessageId()).isNotBlank();
|
||||
assertThat(EmailApi.sendOnce(request(null), "suppressed")).isEqualTo(first);
|
||||
var internal = EmailEffects.send(EmailApiMapper.request(request(null)), "suppressed");
|
||||
assertThat(internal.outcome().name()).isEqualTo(first.outcome().name());
|
||||
assertThat(internal.message().id()).isEqualTo(first.message().id());
|
||||
assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId());
|
||||
assertThat(EmailApi.getMessageByIdempotencyKey("suppressed")).contains(first.message());
|
||||
assertThat(EmailApi.getMessageByRfcMessageId(first.message().rfcMessageId())).contains(first.message());
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Neither a shared-backend transaction nor a deferred callback is a handoff.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendOnceRefusesCallerTransactions() throws Exception
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
for(String backend : new String[] {null, "rdbms", "other"})
|
||||
{
|
||||
EmailApiRequest request = request(null).withTransaction(transaction, backend);
|
||||
assertThat(EmailApi.sendOnce(request, "transaction")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null));
|
||||
assertThat(request.getIdempotencyKey()).isNull();
|
||||
}
|
||||
transaction.commit();
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary sends retain caller transaction behavior through the public mapper.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ordinarySendCanRollBackWithCaller() throws Exception
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
assertThat(EmailApi.send(request("rollback").withTransaction(transaction, "rdbms")).status()).isEqualTo(EmailApiStatus.QUEUED);
|
||||
transaction.rollback();
|
||||
}
|
||||
assertThat(EmailApi.getMessageByIdempotencyKey("rollback")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Uncertain storage failures propagate so dispatchers can retry or reconcile.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sendOncePropagatesStorageFailure() throws Exception
|
||||
{
|
||||
EmailApiEffectResult first = EmailApi.sendOnce(request(null), "collision");
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id()).withValue("idempotencyKey", "changed"));
|
||||
assertThatThrownBy(() -> EmailApi.sendOnce(request(null), "collision")).isInstanceOf(QException.class);
|
||||
assertThat(EmailApi.getMessageByIdempotencyKey("collision")).isEmpty();
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain sends need only QQQ core messaging types at the consumer boundary.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreOnlyBridgeQueuesPlainMail() throws Exception
|
||||
{
|
||||
new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName("qbitEmail")
|
||||
.withTo(new Party().withAddress("pat@example.com")).withSubject("Ready")
|
||||
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Export ready")));
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("subject")).isEqualTo("Ready");
|
||||
assertThat(row.getValueString("status")).isEqualTo("QUEUED");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Template fields are preserved, creation is idempotent, and edits survive.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ensuresAndMapsTemplate() throws Exception
|
||||
{
|
||||
EmailApiTemplate template = template().withLabel("Notice").withKind("HTML").withTextBody("Text {{number}}")
|
||||
.withVariables(List.of(new EmailApiVariable("number", "Number", "STRING", true, "PUBLIC")))
|
||||
.withSampleVariables("{\"number\":\"42\"}").withOwnerQbit("orders").withEditPermissionName("orders.edit");
|
||||
var mapped = EmailApiMapper.template(template);
|
||||
for(Method getter : EmailApiTemplate.class.getDeclaredMethods())
|
||||
{
|
||||
if(getter.getName().startsWith("get") && !Set.of("getVariables", "getVariablesSchema", "getCategory").contains(getter.getName()))
|
||||
{
|
||||
assertThat(mapped.getClass().getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(getter.invoke(template));
|
||||
}
|
||||
}
|
||||
assertThat(mapped.getCategory()).isEqualTo(EmailCategory.TRANSACTIONAL);
|
||||
assertThat(mapped.getVariablesSchema()).contains("number", "Number", "STRING", "true", "PUBLIC");
|
||||
assertThat(EmailApi.ensureTemplate(template)).isTrue();
|
||||
assertThat(EmailApi.ensureTemplate(template.withBody("Replacement"))).isFalse();
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("ownerQbit")).isEqualTo("orders");
|
||||
assertThat(row.getValueString("editPermissionName")).isEqualTo("orders.edit");
|
||||
assertThat(row.getValueBoolean("phiApproved")).isFalse();
|
||||
});
|
||||
assertThat(H2Support.all(EmailTables.VERSION)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("bodySource")).isEqualTo("Hello {{number}}");
|
||||
assertThat(row.getValueString("textSource")).isEqualTo("Text {{number}}");
|
||||
assertThat(row.getValueString("status")).isEqualTo("ACTIVE");
|
||||
});
|
||||
assertThat(EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withVariable("number", "42")).status())
|
||||
.isEqualTo(EmailApiStatus.QUEUED);
|
||||
assertThat(EmailApi.ensureTemplate(template().withName("raw_schema").withVariablesSchema("[]").withBody("Body"))).isTrue();
|
||||
assertThatThrownBy(() -> EmailApi.ensureTemplate(new EmailApiTemplate())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withVariable("number", "Number", "STRING", true, "PUBLIC")
|
||||
.withVariablesSchema("[]"))).hasMessageContaining("not both");
|
||||
assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withName("private_notice")
|
||||
.withVariable(new EmailApiVariable("number", "Number", "STRING", true, "PHI"))
|
||||
.withSampleVariables("{\"number\":\"synthetic\"}"))).hasMessageContaining("PHI");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lookups expose only metadata and handle literal wildcard characters.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void looksUpMetadataAndLiteralPrefixes() throws Exception
|
||||
{
|
||||
EmailApi.ensureTemplate(template().withBody("Body"));
|
||||
int templateId = H2Support.all(EmailTables.TEMPLATE).getFirst().getValueInteger("id");
|
||||
Instant sent = Instant.parse("2030-01-02T03:04:05Z");
|
||||
Instant delivered = sent.plusSeconds(5);
|
||||
EmailApiResult result = EmailApi.send(request("orders:1").withLinkedRecord("orders", 9));
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", result.messageId()).withValue("templateId", templateId)
|
||||
.withValue("status", "DELIVERED").withValue("providerMessageId", "provider-42").withValue("sentDate", sent).withValue("deliveredDate", delivered));
|
||||
EmailApiMessage message = EmailApi.getMessage(result.messageId()).orElseThrow();
|
||||
assertThat(message.id()).isEqualTo(result.messageId());
|
||||
assertThat(message.status()).isEqualTo("DELIVERED");
|
||||
assertThat(message.category()).isEqualTo(EmailApiCategory.TRANSACTIONAL);
|
||||
assertThat(message.idempotencyKey()).isEqualTo("orders:1");
|
||||
assertThat(message.rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>");
|
||||
assertThat(message.providerMessageId()).isEqualTo("provider-42");
|
||||
assertThat(message.templateName()).isEqualTo("notice");
|
||||
assertThat(message.linkedTableName()).isEqualTo("orders");
|
||||
assertThat(message.linkedRecordId()).isEqualTo("9");
|
||||
assertThat(message.createDate()).isNotNull();
|
||||
assertThat(message.sentDate()).isEqualTo(sent);
|
||||
assertThat(message.deliveredDate()).isEqualTo(delivered);
|
||||
assertThat(EmailApi.getMessage(null)).isEmpty();
|
||||
assertThat(EmailApi.getMessage(Integer.MAX_VALUE)).isEmpty();
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("missing", 3)).isEmpty();
|
||||
EmailApi.send(request("orders:2"));
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 1)).containsExactly(message);
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 10)).hasSize(2);
|
||||
for(String prefix : List.of("literal_", "literal%", "literal\\"))
|
||||
{
|
||||
EmailApi.send(request("literalX" + prefix));
|
||||
EmailApiResult matching = EmailApi.send(request(prefix + "match"));
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).extracting(EmailApiMessage::id).containsExactly(matching.messageId());
|
||||
}
|
||||
for(String prefix : new String[] {null, "", " "})
|
||||
{
|
||||
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", 0)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", -1)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(List.of(EmailApiMessage.class.getRecordComponents())).extracting(component -> component.getName())
|
||||
.containsExactly("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId", "templateName", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default selection follows configuration and excludes disabled senders.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void selectsProviderAndEnabledSenders() throws Exception
|
||||
{
|
||||
assertThat(EmailApi.isInstalled(instance)).isTrue();
|
||||
assertThat(EmailApi.isInstalled(null)).isFalse();
|
||||
assertThat(EmailApi.isInstalled(new QInstance())).isFalse();
|
||||
assertThat(EmailApi.defaultProviderName(instance)).contains("relay");
|
||||
assertThat(EmailApi.defaultProviderName(null)).isEmpty();
|
||||
assertThat(EmailApi.listSenderAddresses("unknown")).isEmpty();
|
||||
assertThat(EmailApi.listSenderAddresses("relay")).isEmpty();
|
||||
QRecord first = SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id"))
|
||||
.withValue("address", "first@example.com").withValue("displayName", "First").withValue("enabled", true).withValue("isDefault", true));
|
||||
SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id"))
|
||||
.withValue("address", "disabled@example.com").withValue("enabled", false));
|
||||
assertThat(EmailApi.listSenderAddresses(null)).containsExactly(new EmailApiSenderAddress(first.getValueInteger("id"), "first@example.com", "First", true));
|
||||
EmailApiSenderAddress address = EmailApi.listSenderAddresses("relay").getFirst();
|
||||
assertThat(address.id()).isEqualTo(first.getValueInteger("id"));
|
||||
assertThat(address.address()).isEqualTo("first@example.com");
|
||||
assertThat(address.displayName()).isEqualTo("First");
|
||||
assertThat(address.isDefault()).isTrue();
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
config.withDefaultProviderName("unknown");
|
||||
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
|
||||
config.withDefaultProviderName("relay");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("isDefault", false));
|
||||
assertThat(EmailApi.defaultProviderName(instance)).contains("relay");
|
||||
config.withDefaultProviderName(null);
|
||||
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
|
||||
config.withDefaultProviderName("relay");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false));
|
||||
assertThat(EmailApi.defaultProviderName(instance)).isEmpty();
|
||||
assertThat(EmailApi.listSenderAddresses("relay")).isEmpty();
|
||||
QInstance other = H2Support.instance(H2Support.backend(), c -> c.withSecretResolver(name -> null));
|
||||
assertThatThrownBy(() -> EmailApi.defaultProviderName(other)).hasMessageContaining("current QContext");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Readiness reports each missing prerequisite without leaking resolver data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reportsReadinessAndPhiPrerequisites() throws Exception
|
||||
{
|
||||
assertThat(EmailApi.readiness(null, null).reasons()).containsExactly("Email qbit is not installed");
|
||||
assertThat(EmailApi.readiness(null, null).failedChecks()).containsExactly(EmailApiReadinessCheck.NOT_INSTALLED);
|
||||
assertThat(EmailApi.readiness(instance, null).ready()).isTrue();
|
||||
EmailApiReadiness marketing = EmailApi.readiness(instance, EmailApiCategory.MARKETING);
|
||||
assertThat(marketing.ready()).isFalse();
|
||||
assertThat(marketing.reasons()).hasSize(3);
|
||||
assertThat(String.join(";", marketing.reasons())).contains("publicBaseUrl", "QQQ_EMAIL_UNSUBSCRIBE_KEY", "postal address");
|
||||
assertThat(marketing.failedChecks()).containsExactlyInAnyOrder(EmailApiReadinessCheck.PUBLIC_BASE_URL, EmailApiReadinessCheck.UNSUBSCRIBE_KEY, EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS);
|
||||
assertThat(marketing.failed(EmailApiReadinessCheck.PROVIDER_MISSING)).isFalse();
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "synthetic-test-key");
|
||||
QRecord brand = SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default").withValue("isDefault", true));
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).singleElement().asString().contains("postal address");
|
||||
SystemWrites.updateOne(EmailTables.BRAND, new QRecord().withValue("id", brand.getValue("id")).withValue("postalAddress", "1 Main St"));
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).ready()).isTrue();
|
||||
assertThat(EmailApi.send(request("multi").withTo("other@example.com").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.REFUSED);
|
||||
assertThat(EmailApi.send(request("marketing").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.QUEUED);
|
||||
config.withPublicBaseUrl("javascript:bad");
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs a valid publicBaseUrl");
|
||||
config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name ->
|
||||
{
|
||||
throw (new IllegalStateException("DO_NOT_EXPOSE_RESOLVER_DETAIL"));
|
||||
});
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY");
|
||||
EmailApiReadiness phi = EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL, true);
|
||||
assertThat(phi.reasons()).hasSize(2);
|
||||
assertThat(String.join(";", phi.reasons())).contains("QQQ_EMAIL_BODY_KEY", "PHI-enabled").doesNotContain("DO_NOT_EXPOSE");
|
||||
assertThat(phi.failedChecks()).containsExactlyInAnyOrder(EmailApiReadinessCheck.BODY_KEY, EmailApiReadinessCheck.PHI_PROVIDER);
|
||||
config.withSecretResolver(name -> " ");
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isFalse();
|
||||
config.withSecretResolver(name -> "synthetic-test-key");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id"))
|
||||
.withValue("phiAllowed", true).withValue("trackOpens", false).withValue("trackClicks", false));
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isTrue();
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false));
|
||||
assertThat(EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL).reasons()).containsExactly("An enabled named or default provider is required");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small valid ad-hoc request.
|
||||
*******************************************************************************/
|
||||
private EmailApiRequest request(String key)
|
||||
{
|
||||
return (new EmailApiRequest().withTo("pat@example.com").withSubject("Ready").withTextBody("Body").withIdempotencyKey(key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Template body may be replaced by tests using a different schema.
|
||||
*******************************************************************************/
|
||||
private EmailApiTemplate template()
|
||||
{
|
||||
return (new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("Ready").withBody("Hello {{number}}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the producer and real H2 reads without contacting any transport.
|
||||
*******************************************************************************/
|
||||
class EmailHealthChecksTest
|
||||
{
|
||||
private static final String SENTINEL = "S3NT1NEL-secret";
|
||||
private QInstance instance;
|
||||
private QRecord provider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All credential values are synthetic; transport calls must never occur.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
instance = H2Support.setup(config -> config.withSecretResolver(name -> SENTINEL));
|
||||
provider = H2Support.provider(25);
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id"))
|
||||
.withValue("usernameEnvName", "EMAIL_SMTP_USERNAME").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD")
|
||||
.withValue("security", "STARTTLS").withValue("lastFailureMessage", "IllegalStateException: " + SENTINEL + " recipient@example.test"));
|
||||
EmailRuntime.config().withTransportRegistryFactory(() ->
|
||||
{
|
||||
throw (new AssertionError("Health checks must not create transports"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear the thread-local instance after each isolated database.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production registers a static check and a dynamic supplier with stable ids.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationAndDynamicMembership() throws Exception
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
EmailHealthChecks.register(instance);
|
||||
assertThat(registry.getChecks()).extracting(HealthCheckMetaData::getName).containsExactly("email.readiness");
|
||||
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
|
||||
{
|
||||
assertThat(supplier.getNamePrefix()).isEqualTo("email.provider");
|
||||
assertThat(supplier.getComponent()).isEqualTo("email");
|
||||
});
|
||||
assertThat(registry.resolveAll(instance)).hasSize(2).allSatisfy(check ->
|
||||
{
|
||||
assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*");
|
||||
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(check.getCategory()).isEqualTo(HealthCategory.EMAIL);
|
||||
assertThat(check.getComponent()).isEqualTo("email");
|
||||
assertThat(check.getIntervalSeconds()).isEqualTo(300);
|
||||
assertThat(check.getTimeoutMs()).isEqualTo(5000);
|
||||
});
|
||||
HealthCheck cached = providerCheck();
|
||||
update("enabled", false);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(1);
|
||||
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
update("enabled", true);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(2);
|
||||
SystemWrites.delete(EmailTables.PROVIDER, EmailStore.equal("id", provider.getValueInteger("id")));
|
||||
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolved check observes later delivery transitions, with safe details only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void passiveStatesAndNoSecrets() throws Exception
|
||||
{
|
||||
HealthCheck check = providerCheck();
|
||||
update("healthStatus", "HEALTHY");
|
||||
Instant success = Instant.parse("2026-10-14T12:00:00Z");
|
||||
update("lastSuccessDate", success);
|
||||
var outcome = run(check);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("passive", true).containsEntry("lastSuccessDate", success);
|
||||
update("healthStatus", "UNHEALTHY");
|
||||
outcome = run(check);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).contains("see provider record");
|
||||
for(String state : new String[] {null, SENTINEL})
|
||||
{
|
||||
update("healthStatus", state);
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Marketing gaps degrade the aggregate even while other categories are ready.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessAcrossCategories() throws Exception
|
||||
{
|
||||
var outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues().get("failedChecks").toString().split(","))
|
||||
.containsExactly("PUBLIC_BASE_URL", "BRAND_POSTAL_ADDRESS");
|
||||
assertThat(outcome.getMessage()).contains("Brand postal address is not set").contains("Brand > open the default brand > Postal address").contains("transactional email is unaffected").hasSizeLessThanOrEqualTo(500);
|
||||
assertThat(outcome.getDetails().getValues().get("howToFix").toString()).contains("Public base URL is not set").contains("Brand postal address is not set");
|
||||
configureMarketing();
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
update("enabled", false);
|
||||
outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_MISSING");
|
||||
update("enabled", true);
|
||||
update("host", null);
|
||||
assertThat(run(readinessCheck()).getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_INVALID");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secret resolver exceptions are expected readiness gaps, never diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessResolverFailureIsSecretSafe() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
|
||||
});
|
||||
var outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "UNSUBSCRIBE_KEY");
|
||||
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unexpected database/customizer errors cannot leak their exception text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queryFailuresAreSecretSafe() throws Exception
|
||||
{
|
||||
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
instance.getTable(EmailTables.PROVIDER).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(PoisonedQuery.class));
|
||||
for(HealthCheckMetaData check : checks)
|
||||
{
|
||||
assertThat(run(check.getCheckInstance()).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired deadlines prevent reads; expiration during readiness prevents UP.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void respectsDeadlinesAndCancellation() throws Exception
|
||||
{
|
||||
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
AtomicBoolean queried = new AtomicBoolean();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
queried.set(true);
|
||||
return (SENTINEL);
|
||||
});
|
||||
for(HealthCheckMetaData check : checks)
|
||||
{
|
||||
var outcome = check.getCheckInstance().run(input().withDeadline(Instant.EPOCH));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
clean(outcome);
|
||||
}
|
||||
assertThat(queried).isFalse();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
return (SENTINEL);
|
||||
});
|
||||
try
|
||||
{
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(run(providerCheckFrom(checks)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The health runner has health permissions only; qbit reads use system scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void runsWithHealthOnlyPermissions() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of("health.read"));
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work finishing after its deadline must not report a successful result.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deadlineExpiresDuringReadiness() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
HealthCheckInput input = input();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
input.setDeadline(Instant.EPOCH);
|
||||
return (SENTINEL);
|
||||
});
|
||||
var outcome = readinessCheck().run(input);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
clean(outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal valid marketing prerequisites use a synthetic signing secret.
|
||||
*******************************************************************************/
|
||||
private void configureMarketing() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withPublicBaseUrl("https://app.example.test");
|
||||
SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default")
|
||||
.withValue("isDefault", true).withValue("postalAddress", "1 Main St"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse system mutation semantics for passive fields.
|
||||
*******************************************************************************/
|
||||
private void update(String field, java.io.Serializable value) throws Exception
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue(field, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve dynamic definitions through the real registry.
|
||||
*******************************************************************************/
|
||||
private HealthCheck providerCheck() throws Exception
|
||||
{
|
||||
return (providerCheckFrom(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Obtain the check for this fixture's integer primary key.
|
||||
*******************************************************************************/
|
||||
private HealthCheck providerCheckFrom(List<HealthCheckMetaData> checks)
|
||||
{
|
||||
return (checks.stream().filter(check -> check.getName().equals("email.provider." + provider.getValueInteger("id"))).findFirst().orElseThrow().getCheckInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static readiness definition installed by the producer.
|
||||
*******************************************************************************/
|
||||
private HealthCheck readinessCheck()
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().getFirst().getCheckInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a real deadline rather than the fixture's delivery clock.
|
||||
*******************************************************************************/
|
||||
private HealthCheckInput input()
|
||||
{
|
||||
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runner has package-private sanitize; assert the raw boundary is clean.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(HealthCheck check) throws Exception
|
||||
{
|
||||
var outcome = check.run(input());
|
||||
clean(outcome);
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check both message and serialized details on every success/failure path.
|
||||
*******************************************************************************/
|
||||
private void clean(HealthCheckOutcome outcome)
|
||||
{
|
||||
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SENTINEL, "recipient@example.test", "sender@example.com", "127.0.0.1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject a failure after a real database read, like a failing table customizer.
|
||||
*******************************************************************************/
|
||||
public static class PoisonedQuery extends AbstractPostQueryCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The adapter must discard this untrusted exception message.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.health;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home reads only the central rollup and honors health read permissions.
|
||||
*******************************************************************************/
|
||||
class EmailHealthWidgetTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Release thread-local state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host without central health still has an honest empty status line.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingHealthMetadata() throws Exception
|
||||
{
|
||||
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
assertThat(instance.getWidget("emailHealth").getType()).isEqualTo(WidgetType.STATISTICS.getType());
|
||||
var data = render();
|
||||
assertThat(data.getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(data.getCountContext()).contains("not configured");
|
||||
assertThat(data.getCountURL()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** States come from health_component, never provider rows or free-form errors.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void centralRollupAndPermissions() throws Exception
|
||||
{
|
||||
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
new HealthMetaDataProvider().defineAll(instance, "rdbms", table ->
|
||||
{
|
||||
// H2 needs no additional field metadata.
|
||||
});
|
||||
try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) instance.getBackend("rdbms")))
|
||||
{
|
||||
connection.createStatement().execute("CREATE TABLE health_component (id INT PRIMARY KEY, name VARCHAR(100), status VARCHAR(100))");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(render().getCountContext()).contains("access required");
|
||||
assertThat(render().getCountURL()).isNull();
|
||||
QContext.getQSession().setPermissions(Set.of("email.view", "health.read"));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
assertThat(render().getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(render().getCountContext()).contains("Checks run every 5 minutes");
|
||||
connection.createStatement().execute("INSERT INTO health_component VALUES (1, 'email', 'DOWN'), (2, 'other', 'UP')");
|
||||
assertThat(render().getCount()).isEqualTo("Down");
|
||||
String link = URLDecoder.decode(render().getCountURL(), StandardCharsets.UTF_8);
|
||||
assertThat(link).startsWith("/app/health_check?filter=").contains("component", "email");
|
||||
connection.createStatement().execute("UPDATE health_component SET status = 'UP' WHERE id = 1");
|
||||
assertThat(render().getCount()).isEqualTo("Healthy");
|
||||
connection.createStatement().execute("UPDATE health_component SET status = 'DEGRADED' WHERE id = 1");
|
||||
assertThat(render().getCount()).isEqualTo("Degraded");
|
||||
connection.createStatement().execute("UPDATE health_component SET status = 'S3NT1NEL-secret' WHERE id = 1");
|
||||
assertThat(render().getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(render().getCountContext()).doesNotContain("S3NT1NEL-secret");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render through the same renderer installed in widget metadata.
|
||||
*******************************************************************************/
|
||||
private StatisticsData render() throws Exception
|
||||
{
|
||||
return ((StatisticsData) new EmailHealthWidget().render(new RenderWidgetInput()).getWidgetData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.outbox;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.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.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable handoff semantics and stored identity using the real H2 outbox.
|
||||
*******************************************************************************/
|
||||
class EmailEffectsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Prepare a provider without opening a transport connection.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(config ->
|
||||
{
|
||||
});
|
||||
H2Support.provider(25);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release the per-thread context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A plain message uses the configured sender.
|
||||
*******************************************************************************/
|
||||
private EmailRequest request()
|
||||
{
|
||||
return (new EmailRequest().withTo("person@example.com").withSubject("Effect").withTextBody("Body"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replays return the same persisted reference without another row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void handoffDeduplicatesAndLookupsReturnCurrentReferences() throws Exception
|
||||
{
|
||||
String key = "job:2:row:email";
|
||||
EmailRequest request = request();
|
||||
var first = EmailEffects.send(request, key);
|
||||
var second = EmailEffects.send(request(), key);
|
||||
assertThat(request.getIdempotencyKey()).isEqualTo(key);
|
||||
assertThat(first.outcome()).isEqualTo(EmailEffectOutcome.QUEUED_OR_DUPLICATE);
|
||||
assertThat(second).isEqualTo(first);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
String token = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8))).substring(0, 32);
|
||||
assertThat(first.message().rfcMessageId()).isEqualTo("<" + token + "@example.com>");
|
||||
assertThat(first.message().status()).isEqualTo("QUEUED");
|
||||
assertThat(first.message().providerMessageId()).isNull();
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id())
|
||||
.withValue("status", "SENT").withValue("providerMessageId", "provider-reference"));
|
||||
QContext.init(QContext.getQInstance(), new QSession().withPermissions(List.of()));
|
||||
var found = EmailEffects.findByIdempotencyKey(key).orElseThrow();
|
||||
assertThat(found.id()).isEqualTo(first.message().id());
|
||||
assertThat(found.status()).isEqualTo("SENT");
|
||||
assertThat(found.providerMessageId()).isEqualTo("provider-reference");
|
||||
assertThat(EmailEffects.findByRfcMessageId(first.message().rfcMessageId())).contains(found);
|
||||
assertThat(EmailEffects.findByIdempotencyKey("missing")).isEmpty();
|
||||
assertThat(EmailEffects.findByRfcMessageId("<missing@example.com>")).isEmpty();
|
||||
assertThat(EmailEffects.findByIdempotencyKey(null)).isEmpty();
|
||||
assertThat(EmailEffects.findByRfcMessageId(null)).isEmpty();
|
||||
assertThat(EmailEffects.findByIdempotencyKey(" ")).isEmpty();
|
||||
assertThat(EmailEffects.findByRfcMessageId(" ")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conflicting or unusable requests never enqueue or change an existing key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesConflictingKeysAndInvalidRequests() throws Exception
|
||||
{
|
||||
EmailRequest conflict = request().withIdempotencyKey("other");
|
||||
assertThat(EmailEffects.send(conflict, "effect").outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(conflict.getIdempotencyKey()).isEqualTo("other");
|
||||
assertThat(EmailEffects.send(request(), null).outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(EmailEffects.send(request(), " ").outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(EmailEffects.send(request(), "a".repeat(501)).outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(EmailEffects.send(null, "effect").outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
var invalid = EmailEffects.send(request().withSubject(null), "invalid");
|
||||
assertThat(invalid.outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(invalid.message()).isNull();
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
assertThat(EmailEffects.send(request().withIdempotencyKey("same"), "same").outcome()).isEqualTo(EmailEffectOutcome.QUEUED_OR_DUPLICATE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A suppressed audit row stays refused on replay, including duplicate results.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void suppressedRecipientsAreRefusedOnEveryHandoff() throws Exception
|
||||
{
|
||||
H2Support.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "person@example.com")
|
||||
.withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"));
|
||||
var first = EmailEffects.send(request(), "suppressed");
|
||||
var second = EmailEffects.send(request(), "suppressed");
|
||||
assertThat(first.outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(first.message().status()).isEqualTo("SUPPRESSED");
|
||||
assertThat(second).isEqualTo(first);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never acknowledge an uncommitted or deferred handoff to the jobs dispatcher.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void callerTransactionsAreRefused() throws Exception
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
assertThat(EmailEffects.send(request().withTransaction(transaction, "rdbms"), "same-backend").outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
assertThat(EmailEffects.send(request().withTransaction(transaction, "other"), "other-backend").outcome()).isEqualTo(EmailEffectOutcome.REFUSED);
|
||||
transaction.commit();
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Database uniqueness failures must escape, rather than become terminal refusals.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storageFailurePropagatesAndRfcIdIsUnique() throws Exception
|
||||
{
|
||||
var first = EmailEffects.send(request(), "collision");
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id()).withValue("idempotencyKey", "changed"));
|
||||
assertThatThrownBy(() -> EmailEffects.send(request(), "collision")).isInstanceOf(QException.class);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
assertThat(EmailEffects.findByIdempotencyKey("collision")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity hashing is deterministic, ignores display names and has a safe fallback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void generationAndUnkeyedMessages() throws Exception
|
||||
{
|
||||
assertThat(EmailService.messageId("key", "Display <sender@EXAMPLE.com>"))
|
||||
.isEqualTo(EmailService.messageId("key", "sender@example.com"));
|
||||
for(String from : new String[] {null, "local", "broken <", "sender@[]"})
|
||||
{
|
||||
assertThat(EmailService.messageId("key", from)).matches("<[0-9a-f]{32}@qbit-email\\.local>");
|
||||
}
|
||||
var first = EmailService.send(request());
|
||||
var second = EmailService.send(request());
|
||||
var rows = H2Support.all(EmailTables.MESSAGE);
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows.get(0).getValueString("rfcMessageId")).matches("<[0-9a-f]{32}@example\\.com>")
|
||||
.isNotEqualTo(rows.get(1).getValueString("rfcMessageId"));
|
||||
assertThat(first.messageId()).isNotEqualTo(second.messageId());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user