Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e59b9e6de2 | ||
|
|
96ab97e9b6
|
||
|
|
b2d1c57760
|
||
|
|
e3746649d1
|
||
|
|
a13d4f3b7b
|
||
|
|
8fac3c7181
|
||
|
|
0407a62202 | ||
|
|
846a8814d7
|
||
|
|
bd126ad045
|
||
|
|
7dd863704e
|
||
|
|
9da7038b42
|
||
|
|
44d9ffcda8
|
||
|
|
381014fac1
|
||
|
|
3f4c825e20
|
||
|
|
95ff327bf8 | ||
|
|
f2a4f897e0 | ||
|
|
de925bd4ac | ||
|
|
7b4d98fa60 | ||
|
|
f93480b521 | ||
|
|
2bbe208421 | ||
|
|
bd2724b384 | ||
|
|
64cb2448cf | ||
|
|
2fc7853ec1
|
||
|
|
faad539c60 | ||
|
|
e789f5bd83
|
||
|
|
755a1e2465 | ||
|
|
0c3c1564c3
|
||
|
|
68137d6fff | ||
|
|
ba033f90bc
|
||
|
|
33b70be266 | ||
|
|
d11e38139c
|
||
|
|
a9415f93d9 |
No files matched your search
+10
-61
@@ -1,65 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [ /^v\d+\.\d+\.\d+$/ ]
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -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.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -5,6 +5,11 @@ 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).
|
||||
|
||||
@@ -17,6 +22,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
- `EmailTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
|
||||
|
||||
### Fixed
|
||||
- The `email.readiness` health message now names each missing item in plain words and where to set it (for example "Brand postal address is not set (Email > Brand > open the default brand > Postal address)") and says transactional email is unaffected by marketing-only gaps. The `failedChecks` detail is unchanged; a new `howToFix` detail lists the same text.
|
||||
- Derive the qbit metadata version from the Maven artifact version so released artifacts report their release version.
|
||||
- `email_template.ownerQbit` was stored but never read, and the built-in template did not set it. The built-in now names `qbit-email`, the field is read-only with help, and messages sent from a template name its owner qbit as `senderQbit` unless the request names one.
|
||||
|
||||
|
||||
+10
@@ -1,5 +1,15 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased: shared CRM templates
|
||||
|
||||
Existing hosts and callers need no configuration change. This release adds APIs and a template kind; it does not migrate CRM rows or enable shared CRM templates automatically. The existing VARCHAR kind column needs no email schema change. Hosts with additional kind constraints must allow `CRM_LEGACY` before importing migrated rows.
|
||||
|
||||
- To grant CRM users template access, configure `EmailQBitConfig.withTemplatePermissionBaseName("crm.manage")`. The resulting `.read` and `.write` permissions apply to template/version data and the New Version/Activate Version actions, plus app navigation. They do not grant provider, brand, delivery, message or PHI approval access. A template's `editPermissionName` is still an additional write requirement, and record security locks still apply.
|
||||
- `EmailApi.templateVariableNames` and `renderTemplate` read templates and active versions in the caller's session. Rendering requires an expected delivery category and returns typed content and provenance. Native HTML must not be interpreted as Markdown. Native templates reject category/content overrides, and PHI-approved templates require the existing protected send path. Preview content is not a substitute for the actual queued message subject or delivery envelope.
|
||||
- `EmailApi.sendTemplateOnce` uses the existing outbox and suppression rules, returning the exact stored subject only for a checked template/version and matching sender, linked record, category and single-recipient envelope. Supply sender qbit/process and linked table/id; CC/BCC and multiple parsed mailboxes are unsupported. Existing metadata-only lookup APIs remain unchanged. A replay after active-version or envelope changes can be rejected: reconcile the existing effect rather than automatically retrying with a new key.
|
||||
- `CRM_LEGACY` is for migrated templates owned by `crm`, without PHI approval or record bindings. It preserves the five historical CRM substitutions in a single pass, leaves unknown syntax literal, prefers text source over HTML fallback, and supports historical subject/body overrides. It permits the caller's transactional or marketing category while retaining delivery suppression and readiness checks. Native kinds keep their existing policies.
|
||||
- Release the public email artifact before pinning the adopting CRM release. CRM/host adoption must explicitly opt in, migrate populated rows and references, and preserve provenance and rollback data during a documented maintenance window. Optional tenant CRM hosts must retain their legacy path until a supported tenant migration exists. Local verification artifacts such as `0.1.0-LOCAL-270` must never become public dependency pins.
|
||||
|
||||
## Unreleased: starter defaults, setup card, table help
|
||||
|
||||
1. **Add the defaults changelog** after the changesets that create the `email_*` tables (`docs/ddl/email-postgres.sql`) in the host's master changelog:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<revision>0.2.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -35,7 +35,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -119,7 +119,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.19</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -289,7 +289,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -59,12 +59,30 @@ public class EmailQBitConfig implements QBitConfig
|
||||
private Integer leaseSeconds = 120;
|
||||
private Integer bodyRetentionDays = 30;
|
||||
private Integer messageRetentionDays = 0;
|
||||
private String templatePermissionBaseName;
|
||||
private String appName = "email";
|
||||
private String appLabel = "Email";
|
||||
private List<QFieldMetaData> securityFields = List.of();
|
||||
private List<RecordSecurityLock> recordSecurityLocks = List.of();
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host permission base for template reads/writes only.
|
||||
*******************************************************************************/
|
||||
public String getTemplatePermissionBaseName()
|
||||
{
|
||||
return (templatePermissionBaseName);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Grant no provider, delivery, PHI approval or other email settings access.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withTemplatePermissionBaseName(String templatePermissionBaseName)
|
||||
{
|
||||
this.templatePermissionBaseName = templatePermissionBaseName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -32,9 +32,11 @@ import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailEffects;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.support.EmailApiMapper;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.template.CrmLegacyTemplateRenderer;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateRenderer;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplates;
|
||||
import com.kingsrook.qbits.email.template.VariableSchema;
|
||||
@@ -120,6 +122,167 @@ public final class EmailApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checked single-recipient template handoff for CRM history. Content is returned
|
||||
** only after the stored envelope matches the caller-authorized template/version.
|
||||
** Existing metadata-only APIs deliberately remain unchanged.
|
||||
*******************************************************************************/
|
||||
public static EmailApiTemplateEffectResult sendTemplateOnce(Integer templateId, EmailApiRequest request, String effectKey) throws QException
|
||||
{
|
||||
if(request == null || request.getTo().size() != 1 || !request.getCc().isEmpty() || !request.getBcc().isEmpty()
|
||||
|| request.getSenderQbit() == null || request.getSenderProcess() == null
|
||||
|| request.getLinkedTableName() == null || request.getLinkedRecordId() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires one recipient, sender and linked record"));
|
||||
}
|
||||
List<String> recipients;
|
||||
try
|
||||
{
|
||||
recipients = EmailService.addresses(request.getTo());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires a valid recipient", e));
|
||||
}
|
||||
if(recipients.size() != 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("Checked template delivery requires exactly one mailbox"));
|
||||
}
|
||||
request.withTo(recipients);
|
||||
EmailApiRenderedTemplate selected = renderTemplate(templateId, request);
|
||||
request.withTemplateName(selected.templateName());
|
||||
EmailApiEffectResult effect = sendOnce(request, effectKey);
|
||||
if(effect.outcome() == EmailApiEffectOutcome.REFUSED || effect.message() == null)
|
||||
{
|
||||
return (new EmailApiTemplateEffectResult(effect, null));
|
||||
}
|
||||
List<QRecord> rows = read(EmailTables.MESSAGE, EmailStore.equal("id", effect.message().id()).withLimit(1),
|
||||
Set.of("templateId", "templateVersionId", "senderQbit", "senderProcess", "linkedTableName", "linkedRecordId", "category",
|
||||
"toAddresses", "ccAddresses", "bccAddresses", "suppressedAddresses", "subject"));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QException("Checked template delivery is unavailable"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
if(!java.util.Objects.equals(selected.templateId(), row.getValueInteger("templateId"))
|
||||
|| !java.util.Objects.equals(selected.versionId(), row.getValueInteger("templateVersionId"))
|
||||
|| !java.util.Objects.equals(request.getSenderQbit(), row.getValueString("senderQbit"))
|
||||
|| !java.util.Objects.equals(request.getSenderProcess(), row.getValueString("senderProcess"))
|
||||
|| !java.util.Objects.equals(request.getLinkedTableName(), row.getValueString("linkedTableName"))
|
||||
|| !java.util.Objects.equals(request.getLinkedRecordId(), row.getValueString("linkedRecordId"))
|
||||
|| !java.util.Objects.equals(request.getCategory().name(), row.getValueString("category"))
|
||||
|| !java.util.Objects.equals(request.getTo().getFirst(), row.getValueString("toAddresses"))
|
||||
|| !blank(row.getValueString("ccAddresses")) || !blank(row.getValueString("bccAddresses")) || !blank(row.getValueString("suppressedAddresses")))
|
||||
{
|
||||
throw (new QException("Checked template delivery envelope does not match"));
|
||||
}
|
||||
return (new EmailApiTemplateEffectResult(effect, row.getValueString("subject")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional empty recipient columns are equivalent.
|
||||
*******************************************************************************/
|
||||
private static boolean blank(String value)
|
||||
{
|
||||
return (value == null || value.isBlank());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render as the caller: template and version reads enforce table/record locks.
|
||||
** Native kinds retain their category and classification policy. No PHI export.
|
||||
*******************************************************************************/
|
||||
public static EmailApiRenderedTemplate renderTemplate(Integer templateId, EmailApiRequest request) throws QException
|
||||
{
|
||||
if(templateId == null || request == null || request.getCategory() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template and expected delivery category are required"));
|
||||
}
|
||||
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
|
||||
if(Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
|
||||
{
|
||||
throw (new IllegalArgumentException("PHI-approved templates require the protected template send path"));
|
||||
}
|
||||
QRecord version = visibleActiveVersion(templateId, request.getLocale());
|
||||
QRecord rendered;
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
if(request.getCategory() != EmailApiCategory.TRANSACTIONAL && request.getCategory() != EmailApiCategory.MARKETING)
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
|
||||
}
|
||||
rendered = CrmLegacyTemplateRenderer.render(template, version, request.getVariables(), request.getSubject(), request.getMarkdownBody());
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!Set.of("MARKDOWN", "HTML", "MJML").contains(template.getValueString("kind")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Unsupported template kind"));
|
||||
}
|
||||
if(request.getSubject() != null || request.getMarkdownBody() != null || request.getHtmlBody() != null || request.getTextBody() != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Native template content cannot be overridden"));
|
||||
}
|
||||
var renderer = new EmailTemplateRenderer(template, version, EmailApiMapper.request(request));
|
||||
rendered = renderer.renderContent("render", "");
|
||||
}
|
||||
return (new EmailApiRenderedTemplate(templateId, version.getValueInteger("id"), template.getValueString("name"), template.getValueString("kind"), rendered.getValueString("subject"),
|
||||
rendered.getValueString("markdownBody"), rendered.getValueString("htmlBody"), rendered.getValueString("textBody")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs without exposing content; CRM only computes values in use.
|
||||
*******************************************************************************/
|
||||
public static Set<String> templateVariableNames(Integer templateId) throws QException
|
||||
{
|
||||
QRecord template = visibleTemplateRecord(EmailTables.TEMPLATE, EmailStore.equal("id", templateId));
|
||||
QRecord version = visibleActiveVersion(templateId, "en");
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
String source = String.valueOf(version.getValue("subject")) + version.getValue("bodySource") + version.getValue("textSource");
|
||||
return (Set.of("contact.firstName", "contact.lastName", "company.name", "owner.name", "unsubscribeUrl").stream()
|
||||
.filter(name -> source.contains("{{" + name + "}}")).collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
}
|
||||
return (new VariableSchema(version.getValueString("variablesSchema")).entries().keySet());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read only versions visible to the caller, preferring locale then English.
|
||||
*******************************************************************************/
|
||||
private static QRecord visibleActiveVersion(Integer templateId, String requestedLocale) throws QException
|
||||
{
|
||||
String locale = requestedLocale == null ? "en" : requestedLocale;
|
||||
QueryInput versionQuery = new QueryInput(EmailTables.VERSION).withShouldFetchHeavyFields(true)
|
||||
.withFilter(EmailStore.equal("templateId", templateId).withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
|
||||
new EmailPermissionChecker().checkPermissionsThrowing(versionQuery, QContext.getQInstance().getTable(EmailTables.VERSION));
|
||||
List<QRecord> versions = new QueryAction().execute(versionQuery).getRecords();
|
||||
QRecord version = versions.stream().filter(row -> locale.equals(row.getValueString("locale"))).findFirst()
|
||||
.orElseGet(() -> versions.stream().filter(row -> "en".equals(row.getValueString("locale"))).findFirst().orElse(null));
|
||||
if(version == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template has no visible active version"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary query actions deliberately avoid the email system-write scope.
|
||||
*******************************************************************************/
|
||||
private static QRecord visibleTemplateRecord(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput query = new QueryInput(table).withShouldFetchHeavyFields(true).withFilter(filter);
|
||||
new EmailPermissionChecker().checkPermissionsThrowing(query, QContext.getQInstance().getTable(table));
|
||||
List<QRecord> rows = new QueryAction().execute(query).getRecords();
|
||||
if(rows.size() != 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template is unavailable"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a validated template and active version once. Returns false when
|
||||
** it already exists. Invalid definitions throw IllegalArgumentException.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed content and provenance. HTML must never be reinterpreted as Markdown.
|
||||
*******************************************************************************/
|
||||
public record EmailApiRenderedTemplate(Integer templateId, Integer versionId, String templateName, String kind, String subject,
|
||||
String markdownBody, String htmlBody, String textBody)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.api;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact stored subject for a checked template envelope, never an arbitrary lookup.
|
||||
*******************************************************************************/
|
||||
public record EmailApiTemplateEffectResult(EmailApiEffectResult effect, String subject)
|
||||
{
|
||||
}
|
||||
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -102,7 +103,7 @@ public class EmailHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("email")
|
||||
.withCategory(HealthCategory.EMAIL).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input ->
|
||||
.withIntervalSeconds(EmailHealthWidget.CHECK_INTERVAL_MINUTES * 60).withTimeoutMs(5000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -134,8 +135,38 @@ public class EmailHealthChecks implements HealthCheckSupplier
|
||||
ready &= readiness.ready();
|
||||
failed.addAll(readiness.failedChecks());
|
||||
}
|
||||
return ((ready ? HealthCheckOutcome.up("Email configuration ready") : HealthCheckOutcome.degraded("Email configuration incomplete"))
|
||||
.withDetails(new HealthDetails().withValue("failedChecks", failed.stream().map(Enum::name).collect(Collectors.joining(",")))));
|
||||
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.";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,31 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
*******************************************************************************/
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -72,8 +97,8 @@ public class EmailHealthWidget extends AbstractWidgetRenderer
|
||||
}
|
||||
}
|
||||
}
|
||||
StatisticsData data = new StatisticsData().withCount(status).withCountFontSize("1rem")
|
||||
.withCountContext(!installed ? "Email - health monitoring not configured" : readable ? "Email - System Health" : "Email - health access required");
|
||||
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));
|
||||
|
||||
@@ -202,7 +202,8 @@ public final class EmailEnums
|
||||
{
|
||||
MARKDOWN,
|
||||
MJML,
|
||||
HTML;
|
||||
HTML,
|
||||
CRM_LEGACY;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.kingsrook.qbits.email.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
@@ -30,6 +31,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -58,7 +60,7 @@ public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
throw (new QPermissionDeniedException("Email messages and attempts are written only by the email qbit. Use Send Now, Resend or Cancel."));
|
||||
}
|
||||
if(!has(needed))
|
||||
if(!has(needed) && !hasTemplateGrant(actionInput, metaData))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
@@ -66,6 +68,36 @@ public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Additional host permissions are restricted to template data and lifecycle.
|
||||
*******************************************************************************/
|
||||
private static boolean hasTemplateGrant(AbstractActionInput input, MetaDataWithPermissionRules metaData)
|
||||
{
|
||||
if(metaData instanceof QTableMetaData table
|
||||
&& (EmailTables.TEMPLATE.equals(table.getName()) || EmailTables.VERSION.equals(table.getName())))
|
||||
{
|
||||
return (hasTemplatePermission(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput));
|
||||
}
|
||||
if(metaData instanceof QAppMetaData app && EmailRuntime.config() != null && EmailRuntime.config().getAppName().equals(app.getName()))
|
||||
{
|
||||
return (hasTemplatePermission(false));
|
||||
}
|
||||
return (metaData instanceof QProcessMetaData process
|
||||
&& (EmailProcesses.NEW_VERSION.equals(process.getName()) || EmailProcesses.ACTIVATE_VERSION.equals(process.getName()))
|
||||
&& hasTemplatePermission(true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check only the configured read/write grant, without implying email.manage.
|
||||
*******************************************************************************/
|
||||
public static boolean hasTemplatePermission(boolean write)
|
||||
{
|
||||
var config = EmailRuntime.config();
|
||||
String base = config == null ? null : config.getTemplatePermissionBaseName();
|
||||
QSession session = QContext.getQSession();
|
||||
return (base != null && !base.isBlank() && session != null && session.hasPermission(base + (write ? ".write" : ".read")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission an action needs (null: nobody may do it directly).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.template;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility for migrated CRM content. Customer values are never programs.
|
||||
*******************************************************************************/
|
||||
public final class CrmLegacyTemplateRenderer
|
||||
{
|
||||
private static final Pattern TOKENS = Pattern.compile("\\{\\{(contact.firstName|contact.lastName|company.name|owner.name|unsubscribeUrl)\\}\\}");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmLegacyTemplateRenderer()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the original subject/body override and text-before-HTML behavior.
|
||||
*******************************************************************************/
|
||||
public static QRecord render(QRecord template, QRecord version, Map<String, Serializable> values, String subjectOverride, String bodyOverride)
|
||||
{
|
||||
if(!"crm".equals(template.getValueString("ownerQbit")) || Boolean.TRUE.equals(template.getValueBoolean("phiApproved")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM content requires CRM ownership and cannot be PHI approved"));
|
||||
}
|
||||
String subject = subjectOverride == null || subjectOverride.isBlank() ? version.getValueString("subject") : subjectOverride;
|
||||
String body = bodyOverride == null || bodyOverride.isBlank() ? version.getValueString("textSource") : bodyOverride;
|
||||
if(body == null)
|
||||
{
|
||||
body = version.getValueString("bodySource");
|
||||
}
|
||||
if(subject == null || subject.isBlank() || body == null || body.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("Email subject and body are required"));
|
||||
}
|
||||
subject = replace(subject, values);
|
||||
body = replace(body, values);
|
||||
if(subject.contains("\r") || subject.contains("\n") || (long) subject.getBytes(StandardCharsets.UTF_8).length + body.getBytes(StandardCharsets.UTF_8).length > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or oversized template output"));
|
||||
}
|
||||
return (new QRecord().withValue("subject", subject).withValue("markdownBody", body)
|
||||
.withValue("templateId", template.getValue("id")).withValue("templateVersionId", version.getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace only tokens in the original source, retaining unknown literals.
|
||||
*******************************************************************************/
|
||||
private static String replace(String source, Map<String, Serializable> values)
|
||||
{
|
||||
Matcher matcher = TOKENS.matcher(source);
|
||||
StringBuilder result = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
Serializable value = values.get(matcher.group(1));
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString()));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
return (result.toString());
|
||||
}
|
||||
}
|
||||
@@ -275,9 +275,9 @@ public final class EmailTemplateDefinition
|
||||
{
|
||||
reasons.add("Category is required");
|
||||
}
|
||||
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML").contains(kind))
|
||||
if(kind == null || !List.of("MARKDOWN", "MJML", "HTML", "CRM_LEGACY").contains(kind))
|
||||
{
|
||||
reasons.add("Kind must be MARKDOWN, MJML or HTML");
|
||||
reasons.add("Kind must be MARKDOWN, MJML, HTML or CRM_LEGACY");
|
||||
}
|
||||
if(subject == null || subject.isBlank())
|
||||
{
|
||||
|
||||
@@ -57,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class EmailTemplateRenderer
|
||||
{
|
||||
private final EmailRequest request;
|
||||
private final QRecord template;
|
||||
private final QRecord version;
|
||||
private final QRecord brand;
|
||||
@@ -98,6 +99,7 @@ public final class EmailTemplateRenderer
|
||||
*******************************************************************************/
|
||||
public EmailTemplateRenderer(QRecord template, QRecord version, EmailRequest request) throws QException
|
||||
{
|
||||
this.request = request;
|
||||
this.template = template;
|
||||
this.version = version;
|
||||
approved = Boolean.TRUE.equals(template.getValueBoolean("phiApproved")) && version.getValue("id") != null
|
||||
@@ -117,6 +119,21 @@ public final class EmailTemplateRenderer
|
||||
EmailBrands.validate(brand);
|
||||
schema = new VariableSchema(version.getValueString("variablesSchema"));
|
||||
records = new LinkedHashMap<>(request.getRecordVariables());
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM variables must be supplied from caller-visible records"));
|
||||
}
|
||||
values = new LinkedHashMap<>(request.getVariables());
|
||||
category = request.getCategory() == null ? EmailCategory.valueOf(template.getValueString("category")) : request.getCategory();
|
||||
if(category != EmailCategory.TRANSACTIONAL && category != EmailCategory.MARKETING)
|
||||
{
|
||||
throw (new IllegalArgumentException("Legacy CRM templates support only transactional or marketing delivery"));
|
||||
}
|
||||
CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
|
||||
return;
|
||||
}
|
||||
java.util.Set<String> references = new java.util.LinkedHashSet<>();
|
||||
for(String field : List.of("subject", "preheader", "bodySource", "textSource", "compiledHtml"))
|
||||
{
|
||||
@@ -287,6 +304,40 @@ public final class EmailTemplateRenderer
|
||||
*******************************************************************************/
|
||||
public QRecord render(String messageId, String unsubscribe, boolean encrypt)
|
||||
{
|
||||
return (render(messageId, unsubscribe, encrypt, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render typed content for an existing transport, which adds its own layout.
|
||||
*******************************************************************************/
|
||||
public QRecord renderContent(String messageId, String unsubscribe)
|
||||
{
|
||||
return (render(messageId, unsubscribe, false, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share parsing, classification and output limits across both consumers.
|
||||
*******************************************************************************/
|
||||
private QRecord render(String messageId, String unsubscribe, boolean encrypt, boolean decorate)
|
||||
{
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
QRecord rendered = CrmLegacyTemplateRenderer.render(template, version, values, request.getSubject(), request.getMarkdownBody());
|
||||
String html = EmailMarkdown.render(rendered.getValueString("markdownBody"), brand);
|
||||
if(decorate)
|
||||
{
|
||||
html = EmailLayout.render(html, null, brand, category, unsubscribe);
|
||||
}
|
||||
String text = HtmlToText.convert(html);
|
||||
if((long) rendered.getValueString("subject").getBytes(java.nio.charset.StandardCharsets.UTF_8).length
|
||||
+ html.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + text.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1_048_576)
|
||||
{
|
||||
throw (new IllegalArgumentException("Template output is too large"));
|
||||
}
|
||||
return (rendered.withValue("htmlBody", html).withValue("textBody", text)
|
||||
.withValue("variablesSnapshot", "{}").withValue("brandId", brand.getValue("id"))
|
||||
.withValue("locale", version.getValue("locale")).withValue("category", category.name()).withValue("phiApproved", false));
|
||||
}
|
||||
Map<String, Serializable> context = new LinkedHashMap<>(values);
|
||||
for(String field : List.of("label", "name", "siteUrl", "logoUrl", "logoDarkUrl", "logoAlt", "postalAddress", "primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "darkPrimaryColor", "darkButtonTextColor", "footerMarkdown"))
|
||||
{
|
||||
@@ -310,8 +361,12 @@ public final class EmailTemplateRenderer
|
||||
}
|
||||
String body = engine.parse(source, schema).render(context, "MARKDOWN".equals(kind) ? EmailTemplateEngine.Context.MARKDOWN : EmailTemplateEngine.Context.HTML, budget, warnings);
|
||||
String preheader = engine.parse(version.getValueString("preheader"), schema).render(context, EmailTemplateEngine.Context.TEXT, budget, warnings);
|
||||
String html = "MARKDOWN".equals(kind) ? EmailLayout.render(EmailMarkdown.render(body, brand), preheader, brand, category, unsubscribe)
|
||||
: EmailLayout.appendFooter(body, brand, category, unsubscribe);
|
||||
String html = "MARKDOWN".equals(kind) ? EmailMarkdown.render(body, brand) : body;
|
||||
if(decorate)
|
||||
{
|
||||
html = "MARKDOWN".equals(kind) ? EmailLayout.render(html, preheader, brand, category, unsubscribe)
|
||||
: EmailLayout.appendFooter(html, brand, category, unsubscribe);
|
||||
}
|
||||
if(approved)
|
||||
{
|
||||
html = restrictImages(html);
|
||||
|
||||
@@ -59,7 +59,8 @@ public final class EmailVersions
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE))
|
||||
if(!EmailPermissionChecker.has(approval ? EmailPermissions.ADMIN : EmailPermissions.MANAGE)
|
||||
&& (approval || !EmailPermissionChecker.hasTemplatePermission(true)))
|
||||
{
|
||||
throw (new IllegalArgumentException(approval ? "PHI approval needs email.admin" : "Editing templates needs email.manage"));
|
||||
}
|
||||
@@ -125,9 +126,9 @@ public final class EmailVersions
|
||||
QRecord template = lock(version.getValueInteger("templateId"), tx);
|
||||
requireEdit(template, true);
|
||||
version = requiredVersion(versionId, tx);
|
||||
if("RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")) || "RETIRED".equals(version.getValueString("status")) || "MARKETING".equals(template.getValueString("category")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Retired versions and MARKETING templates cannot be approved for PHI"));
|
||||
throw (new IllegalArgumentException("Legacy CRM templates, retired versions and MARKETING templates cannot be approved for PHI"));
|
||||
}
|
||||
EmailStore.update(EmailTables.TEMPLATE, new QRecord().withValue("id", template.getValue("id"))
|
||||
.withValue("phiApproved", true).withValue("approvedVersionId", versionId).withValue("phiApprovedBy", actor())
|
||||
@@ -176,6 +177,11 @@ public final class EmailVersions
|
||||
public static void validate(QRecord template, QRecord version) throws QException
|
||||
{
|
||||
requireEdit(template, false);
|
||||
if("CRM_LEGACY".equals(template.getValueString("kind")))
|
||||
{
|
||||
CrmLegacyTemplateRenderer.render(template, version, java.util.Map.of(), null, null);
|
||||
return;
|
||||
}
|
||||
if("MJML".equals(template.getValueString("kind")))
|
||||
{
|
||||
CliMjmlCompiler.rejectIncludes(version.getValueString("bodySource"));
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -165,6 +165,8 @@ class EmailHealthChecksTest
|
||||
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);
|
||||
|
||||
@@ -62,7 +62,7 @@ class EmailHealthWidgetTest
|
||||
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("UNKNOWN");
|
||||
assertThat(data.getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(data.getCountContext()).contains("not configured");
|
||||
assertThat(data.getCountURL()).isNull();
|
||||
}
|
||||
@@ -88,15 +88,18 @@ class EmailHealthWidgetTest
|
||||
assertThat(render().getCountURL()).isNull();
|
||||
QContext.getQSession().setPermissions(Set.of("email.view", "health.read"));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
assertThat(render().getCount()).isEqualTo("UNKNOWN");
|
||||
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");
|
||||
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("UP");
|
||||
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("UNKNOWN");
|
||||
assertThat(render().getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(render().getCountContext()).doesNotContain("S3NT1NEL-secret");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user