diff --git a/CHANGELOG.md b/CHANGELOG.md index d735639..a5cf2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java index 8e7e630..b28963c 100644 --- a/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java @@ -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 reasons = new ArrayList<>(); + Set 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 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)); } diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java index a6f6157..92edf01 100644 --- a/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java @@ -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 reasons) +public record EmailApiReadiness(boolean ready, List reasons, Set failedChecks) { /******************************************************************************* ** Copy configuration reasons. @@ -35,5 +37,16 @@ public record EmailApiReadiness(boolean ready, List 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)); } } diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadinessCheck.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadinessCheck.java new file mode 100644 index 0000000..e4ffb12 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadinessCheck.java @@ -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 +} diff --git a/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java index b927900..0901c28 100644 --- a/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java +++ b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java @@ -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");