From 5be0e68ebb0c430c3d27601db12314c5d760eeb9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:39:33 -0500 Subject: [PATCH 1/3] feat(health): register email provider and readiness checks with core central health Co-Authored-By: Claude Opus 5.5 --- .../qbits/email/EmailQBitProducer.java | 2 + .../qbits/email/health/EmailHealthChecks.java | 192 +++++++++ .../qbits/email/health/EmailHealthWidget.java | 83 ++++ .../email/health/EmailHealthChecksTest.java | 387 ++++++++++++++++++ .../email/health/EmailHealthWidgetTest.java | 113 +++++ 5 files changed, 777 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java create mode 100644 src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java create mode 100644 src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java create mode 100644 src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java diff --git a/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java b/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java index f89cc91..2cdf7fc 100644 --- a/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java @@ -28,6 +28,7 @@ import java.util.Map; import com.kingsrook.qbits.email.app.EmailApp; import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData; import com.kingsrook.qbits.email.delivery.EmailAlerts; +import com.kingsrook.qbits.email.health.EmailHealthChecks; import com.kingsrook.qbits.email.metadata.EmailTableCustomizers; import com.kingsrook.qbits.email.metadata.EmailTables; import com.kingsrook.qbits.email.processes.EmailProcesses; @@ -196,6 +197,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer output.add(EmailApp.app(qBitConfig, tables)); output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName())); output.addSelfToInstance(qInstance); + EmailHealthChecks.register(qInstance); registerWebhooks(qInstance); return (new MetaDataProducerMultiOutput()); } diff --git a/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java new file mode 100644 index 0000000..fb07f46 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java @@ -0,0 +1,192 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.email.health; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.email.api.EmailApi; +import com.kingsrook.qbits.email.api.EmailApiCategory; +import com.kingsrook.qbits.email.api.EmailApiReadinessCheck; +import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qbits.email.support.EmailStore; +import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.health.HealthCategory; +import com.kingsrook.qqq.backend.core.health.HealthCheck; +import com.kingsrook.qqq.backend.core.health.HealthCheckInput; +import com.kingsrook.qqq.backend.core.health.HealthCheckKind; +import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData; +import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome; +import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier; +import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData; +import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; +import com.kingsrook.qqq.backend.core.health.HealthDetails; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; + + +/******************************************************************************* + ** Central health adapters reuse readiness and passive delivery state; never send. + *******************************************************************************/ +public class EmailHealthChecks implements HealthCheckSupplier +{ + /******************************************************************************* + ** Register without querying data during metadata production. + *******************************************************************************/ + public static void register(QInstance instance) + { + HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance); + if(registry.getChecks().stream().noneMatch(check -> "email.readiness".equals(check.getName()))) + { + registry.withCheck(definition("email.readiness", EmailHealthChecks::readiness)); + } + if(registry.getSuppliers().stream().noneMatch(supplier -> "email.provider".equals(supplier.getNamePrefix()))) + { + registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("email.provider").withComponent("email") + .withSupplier(new QCodeReference(EmailHealthChecks.class))); + } + } + + + + /******************************************************************************* + ** Provider primary keys are integers, so their names are already lowercase. + ** Capture only identity; each execution rereads current state. + *******************************************************************************/ + @Override + public List supply(QInstance instance) throws QException + { + List checks = new ArrayList<>(); + for(QRecord provider : providers(EmailStore.equal("enabled", true), Set.of("id"))) + { + Integer id = provider.getValueInteger("id"); + checks.add(definition("email.provider." + id, input -> provider(id, input))); + } + return (checks); + } + + + + /******************************************************************************* + ** Bound execution cooperatively around existing reads. Core also enforces + ** the hard timeout. Exception text may contain PHI or secrets: omit it. + *******************************************************************************/ + private static HealthCheckMetaData definition(String name, HealthCheck check) + { + return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("email") + .withCategory(HealthCategory.EMAIL).withKind(HealthCheckKind.DEPENDENCY) + .withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input -> + { + try + { + requireTime(input); + HealthCheckOutcome outcome = check.run(input); + requireTime(input); + return (outcome); + } + catch(Exception e) + { + return (HealthCheckOutcome.down("Email health check failed: " + e.getClass().getSimpleName())); + } + })); + } + + + + /******************************************************************************* + ** Aggregate all category preflights, exposing enum names instead of reasons. + *******************************************************************************/ + private static HealthCheckOutcome readiness(HealthCheckInput input) throws QException + { + Set failed = EnumSet.noneOf(EmailApiReadinessCheck.class); + boolean ready = true; + for(EmailApiCategory category : EmailApiCategory.values()) + { + requireTime(input); + var readiness = EmailApi.readiness(input.getQInstance(), category); + ready &= readiness.ready(); + failed.addAll(readiness.failedChecks()); + } + 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(","))))); + } + + + + /******************************************************************************* + ** The transport SPI has no non-sending connectivity method. Passive state + ** is the only signal; never read or publish lastFailureMessage. + *******************************************************************************/ + private static HealthCheckOutcome provider(Integer id, HealthCheckInput input) throws QException + { + List rows = providers(EmailStore.equal("id", id).withLimit(1), Set.of("id", "enabled", "healthStatus", "lastSuccessDate")); + requireTime(input); + if(rows.isEmpty() || !Boolean.TRUE.equals(rows.getFirst().getValueBoolean("enabled"))) + { + return (HealthCheckOutcome.unknown("Provider disabled or removed")); + } + QRecord row = rows.getFirst(); + String state = row.getValueString("healthStatus"); + HealthCheckOutcome outcome = "HEALTHY".equals(state) ? HealthCheckOutcome.up("Passive provider state healthy") + : "UNHEALTHY".equals(state) ? HealthCheckOutcome.down("Passive provider state unhealthy; see provider record") + : HealthCheckOutcome.unknown("Passive provider state unavailable"); + HealthDetails details = new HealthDetails().withValue("providerId", id).withValue("passive", true); + Instant lastSuccess = row.getValueInstant("lastSuccessDate"); + if(lastSuccess != null) + { + details.withValue("lastSuccessDate", lastSuccess); + } + return (outcome.withDetails(details)); + } + + + + /******************************************************************************* + ** Select only needed fields under the qbit's established system read scope. + *******************************************************************************/ + private static List providers(QQueryFilter filter, Set fields) throws QException + { + return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(EmailTables.PROVIDER).withFilter(filter) + .withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords())); + } + + + + /******************************************************************************* + ** Do not start further work after the runner's deadline or cancellation. + *******************************************************************************/ + private static void requireTime(HealthCheckInput input) throws QException + { + if(Thread.currentThread().isInterrupted() || (input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline()))) + { + throw (new QException("Health check deadline exceeded")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java new file mode 100644 index 0000000..57a7473 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java @@ -0,0 +1,83 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.email.health; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.email.support.EmailStore; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider; +import com.kingsrook.qqq.backend.core.health.HealthStatus; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** One standard status line linking to central checks; no provider diagnostics. + *******************************************************************************/ +public class EmailHealthWidget extends AbstractWidgetRenderer +{ + /******************************************************************************* + ** Read the persisted rollup as the viewer, preserving health permissions. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + String status = HealthStatus.UNKNOWN.name(); + boolean installed = QContext.getQInstance().getTable(HealthMetaDataProvider.COMPONENT_TABLE) != null; + boolean readable = installed && PermissionsHelper.hasTablePermission(input, HealthMetaDataProvider.COMPONENT_TABLE, TablePermissionSubType.READ); + if(readable) + { + List rows = new QueryAction().execute(new QueryInput(HealthMetaDataProvider.COMPONENT_TABLE) + .withFilter(EmailStore.equal("name", "email").withLimit(1)).withFieldNamesToInclude(Set.of("status"))).getRecords(); + if(!rows.isEmpty()) + { + String stored = rows.getFirst().getValueString("status"); + for(HealthStatus candidate : HealthStatus.values()) + { + if(candidate.name().equals(stored)) + { + status = candidate.name(); + } + } + } + } + StatisticsData data = new StatisticsData().withCount(status).withCountFontSize("1rem") + .withCountContext(!installed ? "Email - health monitoring not configured" : readable ? "Email - System Health" : "Email - health access required"); + if(readable) + { + data.withCountURL("/app/health_check?filter=" + URLEncoder.encode(JsonUtils.toJson(EmailStore.equal("component", "email")), StandardCharsets.UTF_8)); + } + return (new RenderWidgetOutput(data)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java new file mode 100644 index 0000000..728e072 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java @@ -0,0 +1,387 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.email.health; + + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import com.kingsrook.qbits.email.EmailRuntime; +import com.kingsrook.qbits.email.H2Support; +import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qbits.email.support.EmailStore; +import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.health.HealthCategory; +import com.kingsrook.qqq.backend.core.health.HealthCheck; +import com.kingsrook.qqq.backend.core.health.HealthCheckInput; +import com.kingsrook.qqq.backend.core.health.HealthCheckKind; +import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData; +import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome; +import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; +import com.kingsrook.qqq.backend.core.health.HealthStatus; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Exercise the producer and real H2 reads without contacting any transport. + *******************************************************************************/ +class EmailHealthChecksTest +{ + private static final String SENTINEL = "S3NT1NEL-secret"; + private QInstance instance; + private QRecord provider; + + + + /******************************************************************************* + ** All credential values are synthetic; transport calls must never occur. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + instance = H2Support.setup(config -> config.withSecretResolver(name -> SENTINEL)); + provider = H2Support.provider(25); + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")) + .withValue("usernameEnvName", "EMAIL_SMTP_USERNAME").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD") + .withValue("security", "STARTTLS").withValue("lastFailureMessage", "IllegalStateException: " + SENTINEL + " recipient@example.test")); + EmailRuntime.config().withTransportRegistryFactory(() -> + { + throw (new AssertionError("Health checks must not create transports")); + }); + } + + + + /******************************************************************************* + ** Clear the thread-local instance after each isolated database. + *******************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** Production registers a static check and a dynamic supplier with stable ids. + *******************************************************************************/ + @Test + void registrationAndDynamicMembership() throws Exception + { + HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance); + EmailHealthChecks.register(instance); + assertThat(registry.getChecks()).extracting(HealthCheckMetaData::getName).containsExactly("email.readiness"); + assertThat(registry.getSuppliers()).singleElement().satisfies(supplier -> + { + assertThat(supplier.getNamePrefix()).isEqualTo("email.provider"); + assertThat(supplier.getComponent()).isEqualTo("email"); + }); + assertThat(registry.resolveAll(instance)).hasSize(2).allSatisfy(check -> + { + assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*"); + assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY); + assertThat(check.getCategory()).isEqualTo(HealthCategory.EMAIL); + assertThat(check.getComponent()).isEqualTo("email"); + assertThat(check.getIntervalSeconds()).isEqualTo(300); + assertThat(check.getTimeoutMs()).isEqualTo(5000); + }); + HealthCheck cached = providerCheck(); + update("enabled", false); + assertThat(registry.resolveAll(instance)).hasSize(1); + assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN); + update("enabled", true); + assertThat(registry.resolveAll(instance)).hasSize(2); + SystemWrites.delete(EmailTables.PROVIDER, EmailStore.equal("id", provider.getValueInteger("id"))); + assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN); + assertThat(registry.resolveAll(instance)).hasSize(1); + } + + + + /******************************************************************************* + ** A resolved check observes later delivery transitions, with safe details only. + *******************************************************************************/ + @Test + void passiveStatesAndNoSecrets() throws Exception + { + HealthCheck check = providerCheck(); + update("healthStatus", "HEALTHY"); + Instant success = Instant.parse("2026-10-14T12:00:00Z"); + update("lastSuccessDate", success); + var outcome = run(check); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP); + assertThat(outcome.getDetails().getValues()).containsEntry("passive", true).containsEntry("lastSuccessDate", success); + update("healthStatus", "UNHEALTHY"); + outcome = run(check); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN); + assertThat(outcome.getMessage()).contains("see provider record"); + for(String state : new String[] {null, SENTINEL}) + { + update("healthStatus", state); + assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN); + } + assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty(); + assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty(); + } + + + + /******************************************************************************* + ** Marketing gaps degrade the aggregate even while other categories are ready. + *******************************************************************************/ + @Test + void readinessAcrossCategories() throws Exception + { + var outcome = run(readinessCheck()); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(outcome.getDetails().getValues().get("failedChecks").toString().split(",")) + .containsExactly("PUBLIC_BASE_URL", "BRAND_POSTAL_ADDRESS"); + configureMarketing(); + assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP); + update("enabled", false); + outcome = run(readinessCheck()); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_MISSING"); + update("enabled", true); + update("host", null); + assertThat(run(readinessCheck()).getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_INVALID"); + } + + + + /******************************************************************************* + ** Secret resolver exceptions are expected readiness gaps, never diagnostics. + *******************************************************************************/ + @Test + void readinessResolverFailureIsSecretSafe() throws Exception + { + configureMarketing(); + EmailRuntime.config().withSecretResolver(name -> + { + throw (new IllegalStateException(SENTINEL + " recipient@example.test")); + }); + var outcome = run(readinessCheck()); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "UNSUBSCRIBE_KEY"); + assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP); + } + + + + /******************************************************************************* + ** Unexpected database/customizer errors cannot leak their exception text. + *******************************************************************************/ + @Test + void queryFailuresAreSecretSafe() throws Exception + { + List checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance); + instance.getTable(EmailTables.PROVIDER).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(PoisonedQuery.class)); + for(HealthCheckMetaData check : checks) + { + assertThat(run(check.getCheckInstance()).getStatus()).isEqualTo(HealthStatus.DOWN); + } + } + + + + /******************************************************************************* + ** Expired deadlines prevent reads; expiration during readiness prevents UP. + *******************************************************************************/ + @Test + void respectsDeadlinesAndCancellation() throws Exception + { + List checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance); + AtomicBoolean queried = new AtomicBoolean(); + EmailRuntime.config().withSecretResolver(name -> + { + queried.set(true); + return (SENTINEL); + }); + for(HealthCheckMetaData check : checks) + { + var outcome = check.getCheckInstance().run(input().withDeadline(Instant.EPOCH)); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN); + clean(outcome); + } + assertThat(queried).isFalse(); + EmailRuntime.config().withSecretResolver(name -> + { + Thread.currentThread().interrupt(); + return (SENTINEL); + }); + try + { + assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.DOWN); + assertThat(run(providerCheckFrom(checks)).getStatus()).isEqualTo(HealthStatus.DOWN); + } + finally + { + Thread.interrupted(); + } + } + + + + /******************************************************************************* + ** The health runner has health permissions only; qbit reads use system scope. + *******************************************************************************/ + @Test + void runsWithHealthOnlyPermissions() throws Exception + { + configureMarketing(); + instance.setEnforcePermissionsInCoreActions(true); + QContext.getQSession().setPermissions(Set.of("health.read")); + assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP); + assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP); + } + + + + /******************************************************************************* + ** Work finishing after its deadline must not report a successful result. + *******************************************************************************/ + @Test + void deadlineExpiresDuringReadiness() throws Exception + { + configureMarketing(); + HealthCheckInput input = input(); + EmailRuntime.config().withSecretResolver(name -> + { + input.setDeadline(Instant.EPOCH); + return (SENTINEL); + }); + var outcome = readinessCheck().run(input); + assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN); + clean(outcome); + } + + + + /******************************************************************************* + ** Minimal valid marketing prerequisites use a synthetic signing secret. + *******************************************************************************/ + private void configureMarketing() throws Exception + { + EmailRuntime.config().withPublicBaseUrl("https://app.example.test"); + SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default") + .withValue("isDefault", true).withValue("postalAddress", "1 Main St")); + } + + + + /******************************************************************************* + ** Reuse system mutation semantics for passive fields. + *******************************************************************************/ + private void update(String field, java.io.Serializable value) throws Exception + { + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue(field, value)); + } + + + + /******************************************************************************* + ** Resolve dynamic definitions through the real registry. + *******************************************************************************/ + private HealthCheck providerCheck() throws Exception + { + return (providerCheckFrom(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance))); + } + + + + /******************************************************************************* + ** Obtain the check for this fixture's integer primary key. + *******************************************************************************/ + private HealthCheck providerCheckFrom(List checks) + { + return (checks.stream().filter(check -> check.getName().equals("email.provider." + provider.getValueInteger("id"))).findFirst().orElseThrow().getCheckInstance()); + } + + + + /******************************************************************************* + ** Static readiness definition installed by the producer. + *******************************************************************************/ + private HealthCheck readinessCheck() + { + return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().getFirst().getCheckInstance()); + } + + + + /******************************************************************************* + ** Use a real deadline rather than the fixture's delivery clock. + *******************************************************************************/ + private HealthCheckInput input() + { + return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5))); + } + + + + /******************************************************************************* + ** The runner has package-private sanitize; assert the raw boundary is clean. + *******************************************************************************/ + private HealthCheckOutcome run(HealthCheck check) throws Exception + { + var outcome = check.run(input()); + clean(outcome); + return (outcome); + } + + + + /******************************************************************************* + ** Check both message and serialized details on every success/failure path. + *******************************************************************************/ + private void clean(HealthCheckOutcome outcome) + { + assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SENTINEL, "recipient@example.test", "sender@example.com", "127.0.0.1"); + } + + + + /******************************************************************************* + ** Inject a failure after a real database read, like a failing table customizer. + *******************************************************************************/ + public static class PoisonedQuery extends AbstractPostQueryCustomizer + { + /******************************************************************************* + ** The adapter must discard this untrusted exception message. + *******************************************************************************/ + @Override + public List apply(List records) + { + throw (new IllegalStateException(SENTINEL + " recipient@example.test")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java new file mode 100644 index 0000000..15a81bb --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java @@ -0,0 +1,113 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.email.health; + + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import com.kingsrook.qbits.email.H2Support; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The home reads only the central rollup and honors health read permissions. + *******************************************************************************/ +class EmailHealthWidgetTest +{ + /******************************************************************************* + ** Release thread-local state. + *******************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** A host without central health still has an honest empty status line. + *******************************************************************************/ + @Test + void missingHealthMetadata() throws Exception + { + var instance = H2Support.setup(config -> config.withSecretResolver(name -> null)); + assertThat(instance.getWidget("emailHealth").getType()).isEqualTo(WidgetType.STATISTICS.getType()); + var data = render(); + assertThat(data.getCount()).isEqualTo("UNKNOWN"); + assertThat(data.getCountContext()).contains("not configured"); + assertThat(data.getCountURL()).isNull(); + } + + + + /******************************************************************************* + ** States come from health_component, never provider rows or free-form errors. + *******************************************************************************/ + @Test + void centralRollupAndPermissions() throws Exception + { + var instance = H2Support.setup(config -> config.withSecretResolver(name -> null)); + new HealthMetaDataProvider().defineAll(instance, "rdbms", table -> + { + // H2 needs no additional field metadata. + }); + try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) instance.getBackend("rdbms"))) + { + connection.createStatement().execute("CREATE TABLE health_component (id INT PRIMARY KEY, name VARCHAR(100), status VARCHAR(100))"); + QContext.getQSession().setPermissions(Set.of("email.view")); + assertThat(render().getCountContext()).contains("access required"); + assertThat(render().getCountURL()).isNull(); + QContext.getQSession().setPermissions(Set.of("email.view", "health.read")); + instance.setEnforcePermissionsInCoreActions(true); + assertThat(render().getCount()).isEqualTo("UNKNOWN"); + connection.createStatement().execute("INSERT INTO health_component VALUES (1, 'email', 'DOWN'), (2, 'other', 'UP')"); + assertThat(render().getCount()).isEqualTo("DOWN"); + String link = URLDecoder.decode(render().getCountURL(), StandardCharsets.UTF_8); + assertThat(link).startsWith("/app/health_check?filter=").contains("component", "email"); + connection.createStatement().execute("UPDATE health_component SET status = 'UP' WHERE id = 1"); + assertThat(render().getCount()).isEqualTo("UP"); + connection.createStatement().execute("UPDATE health_component SET status = 'S3NT1NEL-secret' WHERE id = 1"); + assertThat(render().getCount()).isEqualTo("UNKNOWN"); + assertThat(render().getCountContext()).doesNotContain("S3NT1NEL-secret"); + } + } + + + + /******************************************************************************* + ** Render through the same renderer installed in widget metadata. + *******************************************************************************/ + private StatisticsData render() throws Exception + { + return ((StatisticsData) new EmailHealthWidget().render(new RenderWidgetInput()).getWidgetData()); + } +} -- 2.54.0 From fea8f8d4450e757ff69bc0b3bc9553a3e01af1b3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:39:34 -0500 Subject: [PATCH 2/3] refactor(email): home shows the central health status instead of its own provider health list Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/email/app/EmailApp.java | 4 ++- .../kingsrook/qbits/email/app/EmailHome.java | 18 +---------- .../qbits/email/app/EmailHomeView.java | 30 +------------------ .../qbits/email/EmailHomePolishTest.java | 15 +++++----- .../qbits/email/EmailTemplatesTest.java | 4 +-- 5 files changed, 14 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java b/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java index 43c4225..21075d3 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java @@ -24,6 +24,7 @@ package com.kingsrook.qbits.email.app; import java.util.List; import java.util.Map; import com.kingsrook.qbits.email.EmailQBitConfig; +import com.kingsrook.qbits.email.health.EmailHealthWidget; import com.kingsrook.qbits.email.metadata.EmailTables; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -64,6 +65,7 @@ public final class EmailApp { return (List.of( widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class), + widget("emailHealth", "Email health", WidgetType.STATISTICS.getType(), EmailHealthWidget.class), widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class))); } @@ -80,7 +82,7 @@ public final class EmailApp .withLabel(config.getAppLabel()) .withIcon(new QIcon("mail")) .withPermissionRules(EmailTables.permissionRules()) - .withWidgets(List.of(HOME_WIDGET)); + .withWidgets(List.of("emailHealth", HOME_WIDGET)); for(String name : EmailTables.MAIN) { if(tables.containsKey(name)) diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java b/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java index 60c5568..3872bf0 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java @@ -30,8 +30,6 @@ import java.util.List; import java.util.Locale; import com.kingsrook.qbits.email.EmailRuntime; import com.kingsrook.qbits.email.metadata.EmailTables; -import com.kingsrook.qbits.email.permissions.EmailPermissionChecker; -import com.kingsrook.qbits.email.permissions.EmailPermissions; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -87,21 +85,7 @@ public final class EmailHome totals.add(count(EmailTables.SUPPRESSION, new QQueryFilter())); List messages = query(EmailTables.MESSAGE, new QQueryFilter().withOrderBy(new QFilterOrderBy("createDate", false)) .withOrderBy(new QFilterOrderBy("id", false)).withLimit(20), List.of("id", "createDate", "toAddresses", "subject", "phiApproved", "status")); - boolean admin = EmailPermissionChecker.has(EmailPermissions.ADMIN); - List providers = admin ? query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)), - List.of("id", "label", "name", "enabled", "healthStatus", "lastSuccessDate", "lastFailureMessage", "maxPerMinute")) : List.of(); - for(QRecord provider : providers) - { - Integer limit = provider.getValueInteger("maxPerMinute"); - if(limit != null) - { - int used = count(EmailTables.ATTEMPT, since("startDate", now.minusSeconds(60)) - .withCriteria("startDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now) - .withCriteria("providerId", QCriteriaOperator.EQUALS, provider.getValue("id"))); - provider.setValue("headroom", Math.max(0, limit - used)); - } - } - return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions()))); + return (new RawHTML(null, EmailHomeView.render(totals, hourly(), messages, suppressions()))); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java b/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java index 806bff3..d63cd8d 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java @@ -43,7 +43,6 @@ public final class EmailHomeView private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;"; private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);"; private static final String FAIL = "#d64545"; - private static final String OK = "#2e9e5b"; private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC); @@ -60,7 +59,7 @@ public final class EmailHomeView /******************************************************************************* ** One ordered home with real, viewer-scoped data supplied by EmailHome. *******************************************************************************/ - public static String render(List totals, ChartData hourly, List providers, boolean admin, List messages, ChartData suppressions) + public static String render(List totals, ChartData hourly, List messages, ChartData suppressions) { StringBuilder html = new StringBuilder("
"); html.append("
"); @@ -74,7 +73,6 @@ public final class EmailHomeView } html.append("
"); bars(html, "Sends per hour, last 24 hours", hourly); - providers(html, providers, admin); messages(html, messages); bars(html, "Suppressions added, last 30 days", suppressions); return (html.append("
").toString()); @@ -138,32 +136,6 @@ public final class EmailHomeView - /******************************************************************************* - ** Provider health is admin-only; disabled providers never appear healthy. - *******************************************************************************/ - private static void providers(StringBuilder html, List providers, boolean admin) - { - section(html, "Provider health"); - if(!admin || providers.isEmpty()) - { - html.append("

").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("

"); - } - for(QRecord provider : providers) - { - String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled" - : "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown"; - String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080"; - html.append("
") - .append(cell("Provider", "" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "
" + status + "")) - .append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate"))))) - .append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None")))) - .append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("
"); - } - html.append(""); - } - - - /******************************************************************************* ** The whole row is a link. Labelled columns stack as cards on narrow screens. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java b/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java index cac3e59..92d8dfa 100644 --- a/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java +++ b/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java @@ -108,8 +108,8 @@ class EmailHomePolishTest assertThat(tiles.get(i).child(0).attr("style")).contains("font-size:1.6rem"); assertThat(tiles.get(i).child(0).attr("style").contains("#d64545")).isEqualTo(i == 2 || i == 3); } - assertThat(document.select("h3")).extracting(Element::text).containsExactly("Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days"); - assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome"); + assertThat(document.select("h3")).extracting(Element::text).containsExactly("Sends per hour, last 24 hours", "Recent messages", "Suppressions added, last 30 days"); + assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHealth", "emailHome"); } /******************************************************************************* @@ -147,7 +147,7 @@ class EmailHomePolishTest ** Twenty newest linked rows wrap long text, mask addresses, and hide PHI. *******************************************************************************/ @Test - void recentRowsAndProviderHealth() throws Exception + void recentRowsExcludeProviderDiagnostics() throws Exception { for(int i = 0; i < 21; i++) { @@ -165,13 +165,12 @@ class EmailHomePolishTest assertThat(rows.first().attr("href")).isEqualTo("/app/email_message/21"); assertThat(rows.last().attr("href")).isEqualTo("/app/email_message/2"); assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry"); - assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "<broken>", "min-width:0", "overflow-wrap:anywhere") - .doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T"); - assertThat(document.select("section").get(1).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1"); + assertThat(html).contains("Secure message", "p***@", "min-width:0", "overflow-wrap:anywhere") + .doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T", "Provider health", "<broken>", "Per-minute headroom"); SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null)); - assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit"); + assertThat(EmailHome.overview().getHtml()).doesNotContain("Disabled", "No limit", "Provider health"); QContext.getQSession().setPermissions(Set.of("email.view")); - assertThat(EmailHome.overview().getHtml()).contains("Provider health needs email.admin").doesNotContain("<broken>"); + assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "<broken>"); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java b/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java index cc08cb9..41e0f23 100644 --- a/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java +++ b/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java @@ -221,7 +221,7 @@ class EmailTemplatesTest @Test void previewAndHome() throws Exception { - assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("

Preview text

"); @@ -233,7 +233,7 @@ class EmailTemplatesTest EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person ")); assertThat(EmailHome.overview().getHtml()).contains("p***@example.com", "/app/email_message/"); QContext.getQSession().setPermissions(Set.of("email.view")); - assertThat(EmailHome.overview().getHtml()).contains("needs email.admin"); + assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "needs email.admin"); } /******************************************************************************* -- 2.54.0 From 0122fb9fc19849a258bb09170fcc512344090293 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:39:34 -0500 Subject: [PATCH 3/3] docs: email health checks Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 30 ++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cf2bb..9c5845e 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 +- Central `email.provider.` and `email.readiness` health checks (DEPENDENCY, EMAIL, 300 seconds), using passive provider state and existing category readiness with deadline and secret-safety coverage. Standard Email health status widget links to central checks. Requires the unreleased QQQ health SPI; POM version unchanged. - `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. @@ -36,3 +37,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - QBIT_EMAIL core messaging bridge, Review/Result actions, backend send process, dispatch/maintenance/retention schedules and host integration declarations. - H2 concurrency and transaction tests, GreenMail end-to-end tests, fake SES tests, metadata/permission checks and process-runner coverage. - Repository scaffold to the QRun qbit repository standard (Apache-2.0, CircleCI qqq-orb 0.6.8 with the `configure-package-install` pre-step, GA-only `publish_release`, the `release-rc` caller workflow). + +### Removed +- The Email home's standalone Provider health listing, including per-provider failure messages, last-success details and rate-headroom queries. Passive delivery health and `email.provider.unhealthy` alerts are retained. diff --git a/README.md b/README.md index c7e5c1c..60d91ce 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,32 @@ and credential failures pause the provider. Tracking is explicitly controlled by `trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c) receiver idempotently when the provided Javalin middleware is present. +## Health checks + +The producer registers these checks with QQQ central health monitoring: + +| Name | Kind | Interval | +|---|---|---| +| `email.provider.` (each enabled provider) | DEPENDENCY | 300 seconds | +| `email.readiness` | DEPENDENCY | 300 seconds | + +Both use component `email`, category `EMAIL`, and a 5-second timeout. Provider +checks read passive delivery state only: HEALTHY maps to UP, UNHEALTHY to DOWN, +and missing/disabled/unknown state to UNKNOWN. The transport SPI has no +non-sending connectivity method; these checks never contact providers or send +mail. Readiness reuses `EmailApi.readiness` for every `EmailApiCategory`; any +unmet prerequisite is DEGRADED, with only enum names in `failedChecks`. Existing +passive state and `email.provider.unhealthy` alerts remain in place. + +The Email home uses a standard status widget reading the central `health_component` +rollup, with a link to System Health's checks filtered to `email`. It no longer +lists provider diagnostics. The host must install central health metadata and its +schema; central reads use the viewer's health permissions. + +This unreleased integration requires the core health SPI currently installed as +`4.1.0-SNAPSHOT`; the POM's RC.5 pin is unchanged. Verify offline with +`./mvnw -o verify -Dqqq.snapshot.version=4.1.0-SNAPSHOT` (`mvnw.cmd` on Windows). + ## Sending from another qbit There are two supported routes. Both queue through the durable outbox; a successful @@ -434,8 +460,8 @@ return 404. The legacy token covers every To/Cc/Bcc recipient, so use one mailbo per opt-out message for individual preferences. Preferences UI remains deferred until core #1162 ships in an RC. -The home uses viewer-scoped counts and queries, UTC hour/day charts, provider health -(admins), and 20 recent messages with masked recipients. No sample data or sampled +The home uses viewer-scoped counts and queries, UTC hour/day charts, a central +health status line, and 20 recent messages with masked recipients. No sample data or sampled counts are used. Delivered/bounced/complained totals count recipient events and use their occurred dates; sent/dead-letter totals count messages. -- 2.54.0