Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 28b964ab17 feat(api): structured readiness checks
EmailApiReadiness carries failedChecks (EmailApiReadinessCheck) next to
the plain reasons, so callers such as qbit-marketing can tell a missing
unsubscribe key from a missing provider without parsing text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:12:31 -05:00
james.maes d2bddc968a Merge pull request #13 from QRun-IO/feature/public-api
feat: public api package for other qbits
2026-10-14 09:44:03 -05:00
5 changed files with 68 additions and 3 deletions

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
### Added
- `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.
@@ -22,6 +22,7 @@ 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;
@@ -283,21 +284,24 @@ public final class EmailApi
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")));
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)
{
@@ -308,6 +312,7 @@ public final class EmailApi
catch(IllegalArgumentException e)
{
reasons.add("PHI email needs a PHI-enabled provider with open and click tracking disabled");
failed.add(EmailApiReadinessCheck.PHI_PROVIDER);
}
}
}
@@ -324,22 +329,26 @@ public final class EmailApi
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));
return (new EmailApiReadiness(reasons.isEmpty(), reasons, failed));
}
@@ -21,13 +21,15 @@
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)
public record EmailApiReadiness(boolean ready, List<String> reasons, Set<EmailApiReadinessCheck> failedChecks)
{
/*******************************************************************************
** Copy configuration reasons.
@@ -35,5 +37,16 @@ public record EmailApiReadiness(boolean ready, List<String> 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
}
@@ -499,11 +499,14 @@ class EmailApiTest
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));
@@ -522,6 +525,7 @@ class EmailApiTest
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");