From 78ad854f4144906c1c3a0af4623a5304e37f68c3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:30:56 -0500 Subject: [PATCH] refactor(email): use qbit-email's public api package (fixes #13) QbitEmailTransport and MarketingRuntime use com.kingsrook.qbits.email.api from qbit-email 0.1.0-RC.3; getStatus() keeps its mapping through EmailApiReadiness.failedChecks (NOT_CONFIGURED only for the base URL and unsubscribe key; a missing provider or brand address is still REJECTED at send). qbit-build-parent 2.1.1 allows qbit-email's api by default, so the temporary email allowances are gone; only the existing assistant provider SPI allowances remain. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 4 +- pom.xml | 12 ++-- .../qbits/marketing/MarketingRuntime.java | 2 +- .../marketing/email/QbitEmailTransport.java | 68 +++++++++--------- .../email/QbitEmailTransportTest.java | 72 +++++++++++++++---- 6 files changed, 102 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51dd6c9..a7511c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed +- Switch the optional qbit-email integration to the published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.3` (fixes #13). Preserve MARKETING messages, idempotency keys, execution links and outcome mapping; use typed readiness failures so missing providers or brand postal addresses remain send-time rejections. Upgrade `qbit-build-parent` to `2.1.1` and remove all per-repository guardrail allow-lists. - Resolve architecture finding F5 (#9): depend on `qbit-assistant-provider-api` and `qbit-assistant-provider-openai` `0.1.0-RC.11`, with no full assistant dependency, including in tests. - Replace the local OpenAI-compatible implementation with the published provider and document `ProviderSettings`, environment-key resolution and provider-name migration. Agent tests use a local scripted double. -- Upgrade QQQ to `4.1.0-RC.6` and `qbit-build-parent` to `2.1.0`; enable architecture enforcement and strict UI API routes with explicit provider API/artifact allowances. +- Upgrade QQQ to `4.1.0-RC.6`; enable architecture enforcement and strict UI API routes. ### Added - Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references. diff --git a/README.md b/README.md index 6520953..527d773 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > Marketing for QQQ applications: products and their verified evidence, campaigns, content with revision history, exact human approval, controlled execution, honest measurement, and a marketing agent that prepares work but never publishes. **Status:** 0.1.0, not yet published. -- Built against QQQ `4.1.0-RC.6` with `qbit-build-parent` `2.1.0`. +- Built against QQQ `4.1.0-RC.6` with `qbit-build-parent` `2.1.1`. - Hosted in QRun-IO/Voyage next to qbit-crm, on a local branch. - The real integrations are implemented but unconfigured. See [what is configured](#what-is-implemented-what-is-not-configured). @@ -107,7 +107,7 @@ When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is pr - Idempotency key `marketing:::`; the linked record is the `marketing_execution` row. A repeat is a duplicate, never a second send. - Accepted means queued in the qbit-email outbox. A suppressed recipient is skipped, not failed (the result says how many); a refusal by qbit-email is a rejected execution with its reason. - Needs qbit-email's `publicBaseUrl`, the `QQQ_EMAIL_UNSUBSCRIBE_KEY` secret, an enabled default provider and a default brand with a postal address. Without the first two the transport reports *Not configured* and says which is missing. -- qbit-email is an optional dependency (`qbit-email.version`, a SNAPSHOT until its first RC) and needs QQQ `4.1.0-RC.5`. +- qbit-email is an optional dependency pinned to `0.1.0-RC.3` from Maven Central (`qbit-email.version`). The integration uses only its published `com.kingsrook.qbits.email.api` package for installation checks, readiness, sends and lookups, with the parent's default guardrail allow-lists. ## What is implemented, what is not configured diff --git a/pom.xml b/pom.xml index c625eae..2b1c1a8 100644 --- a/pom.xml +++ b/pom.xml @@ -26,19 +26,19 @@ 0.1.0-RC.11 true true - - - com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.assistant.provider,com.kingsrook.qbits.assistant.engine,com.kingsrook.qbits.email - com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-assistant-provider-openai,com.kingsrook.qbits:qbit-email - 0.1.0-RC.1 + + + com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.assistant.provider,com.kingsrook.qbits.assistant.engine + com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-assistant-provider-openai + 0.1.0-RC.3 2.3.232 com.kingsrook qbit-build-parent - 2.1.0 + 2.1.1 com.kingsrook.qbits diff --git a/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java b/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java index e6c5b1f..f016385 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java +++ b/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java @@ -168,7 +168,7 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData { try { - Class.forName("com.kingsrook.qbits.email.outbox.EmailService"); + Class.forName("com.kingsrook.qbits.email.api.EmailApi"); return (true); } catch(ClassNotFoundException | LinkageError e) diff --git a/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java b/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java index 1b1b9b8..1b4bb8d 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java +++ b/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java @@ -20,23 +20,19 @@ package com.kingsrook.qbits.marketing.email; import java.util.EnumSet; import java.util.List; import java.util.Set; -import com.kingsrook.qbits.email.EmailQBitConfig; -import com.kingsrook.qbits.email.EmailRuntime; -import com.kingsrook.qbits.email.metadata.EmailTables; -import com.kingsrook.qbits.email.outbox.EmailCategory; -import com.kingsrook.qbits.email.outbox.EmailRequest; -import com.kingsrook.qbits.email.outbox.EmailSendResult; -import com.kingsrook.qbits.email.outbox.EmailService; -import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qbits.email.api.EmailApi; +import com.kingsrook.qbits.email.api.EmailApiCategory; +import com.kingsrook.qbits.email.api.EmailApiMessage; +import com.kingsrook.qbits.email.api.EmailApiReadiness; +import com.kingsrook.qbits.email.api.EmailApiReadinessCheck; +import com.kingsrook.qbits.email.api.EmailApiRequest; +import com.kingsrook.qbits.email.api.EmailApiResult; import com.kingsrook.qbits.marketing.model.MarketingExecution; import com.kingsrook.qbits.marketing.model.enums.ChannelType; import com.kingsrook.qbits.marketing.transport.MarketingTransport; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.utils.StringUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -88,8 +84,8 @@ public class QbitEmailTransport implements MarketingTransport { try { - Class.forName("com.kingsrook.qbits.email.outbox.EmailService"); - return (EmailRuntime.config(qInstance) != null); + Class.forName("com.kingsrook.qbits.email.api.EmailApi"); + return (EmailApi.isInstalled(qInstance)); } catch(ClassNotFoundException | LinkageError e) { @@ -119,28 +115,30 @@ public class QbitEmailTransport implements MarketingTransport @Override public TransportStatus getStatus() { - EmailQBitConfig emailConfig = isInstalled(QContext.getQInstance()) ? EmailRuntime.config() : null; - if(emailConfig == null) + if(!isInstalled(QContext.getQInstance())) { return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "The email qbit (qbit-email) is not installed in this application.")); } - if(!StringUtils.hasContent(emailConfig.getPublicBaseUrl())) - { - return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the email qbit's publicBaseUrl (the unsubscribe link base).")); - } - - String key = null; try { - key = emailConfig.getSecretResolver().apply(UNSUBSCRIBE_KEY_NAME); + EmailApiReadiness readiness = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING); + if(readiness.failed(EmailApiReadinessCheck.NOT_INSTALLED)) + { + return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "The email qbit (qbit-email) is not installed in this application.")); + } + if(readiness.failed(EmailApiReadinessCheck.PUBLIC_BASE_URL)) + { + return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the email qbit's publicBaseUrl (the unsubscribe link base).")); + } + if(readiness.failed(EmailApiReadinessCheck.UNSUBSCRIBE_KEY)) + { + return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the " + UNSUBSCRIBE_KEY_NAME + " secret (it signs the unsubscribe links).")); + } } - catch(Exception e) + catch(QException e) { - LOG.warn("Could not resolve the unsubscribe key", logPair("error", e.getClass().getSimpleName())); - } - if(!StringUtils.hasContent(key)) - { - return (new TransportStatus(TransportStatus.State.NOT_CONFIGURED, "Marketing email needs the " + UNSUBSCRIBE_KEY_NAME + " secret (it signs the unsubscribe links).")); + LOG.warn("Could not check email qbit readiness", logPair("error", e.getClass().getSimpleName())); + return (new TransportStatus(TransportStatus.State.CONFIGURED, "Could not check the email qbit's configuration; retry when its configuration can be read.")); } return (new TransportStatus(TransportStatus.State.OPERATIONAL, "Queues through the email qbit's outbox with its unsubscribe link and suppression list.")); } @@ -183,7 +181,7 @@ public class QbitEmailTransport implements MarketingTransport try { - EmailSendResult result = EmailService.send(emailRequest(request, recipient)); + EmailApiResult result = EmailApi.send(emailRequest(request, recipient)); switch(result.status()) { case QUEUED -> queued++; @@ -227,8 +225,8 @@ public class QbitEmailTransport implements MarketingTransport { try { - String prefix = "marketing:" + stripPrefix(idempotencyKey) + ":"; - List rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, prefix)).withLimit(1)); + String prefix = "marketing:" + stripPrefix(idempotencyKey) + ":"; + List rows = EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1); if(rows.isEmpty()) { return (new TransportLookup(TransportLookup.State.NOT_FOUND, null)); @@ -247,7 +245,7 @@ public class QbitEmailTransport implements MarketingTransport /******************************************************************************* ** One MARKETING message to one recipient. *******************************************************************************/ - private EmailRequest emailRequest(TransportRequest request, Recipient recipient) + private EmailApiRequest emailRequest(TransportRequest request, Recipient recipient) { String body = request.body() == null ? "" : request.body(); if(StringUtils.hasContent(request.destinationUrl()) && request.destinationUrl().matches("(?i)https?://\\S+")) @@ -256,11 +254,11 @@ public class QbitEmailTransport implements MarketingTransport body = body + "\n\n[" + label + "](" + request.destinationUrl() + ")"; } - return (new EmailRequest() + return (new EmailApiRequest() .withTo(recipient.address()) .withSubject(request.title() == null ? "" : request.title().replaceAll("[\\r\\n]+", " ").trim()) .withMarkdownBody(body) - .withCategory(EmailCategory.MARKETING) + .withCategory(EmailApiCategory.MARKETING) .withIdempotencyKey(baseKey(request) + ":" + recipient.key()) .withLinkedRecord(MarketingExecution.TABLE_NAME, request.executionId()) .withSender("marketing", "execution")); diff --git a/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java b/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java index fe9872a..6a4f8f7 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java @@ -21,9 +21,9 @@ import java.util.ArrayList; import java.util.List; import com.kingsrook.qbits.email.EmailQBitConfig; import com.kingsrook.qbits.email.EmailQBitProducer; -import com.kingsrook.qbits.email.metadata.EmailTables; -import com.kingsrook.qbits.email.permissions.EmailPermissions; -import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qbits.email.api.EmailApi; +import com.kingsrook.qbits.email.api.EmailApiCategory; +import com.kingsrook.qbits.email.api.EmailApiMessage; import com.kingsrook.qbits.marketing.MarketingQBitConfig; import com.kingsrook.qbits.marketing.MarketingQBitProducer; import com.kingsrook.qbits.marketing.MarketingRuntime; @@ -34,10 +34,13 @@ import com.kingsrook.qbits.marketing.transport.MarketingTransport.Recipient; import com.kingsrook.qbits.marketing.transport.MarketingTransport.TransportRequest; import com.kingsrook.qbits.marketing.transport.MarketingTransport.TransportResult; import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +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.instances.QInstanceValidator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; @@ -90,7 +93,7 @@ class QbitEmailTransportTest new QInstanceValidator().validate(qInstance); List permissions = new ArrayList<>(MarketingPermissions.ALL); - permissions.addAll(EmailPermissions.ALL); + permissions.addAll(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess")); QContext.init(qInstance, new QSession().withPermissions(permissions)); return (qInstance); } @@ -103,7 +106,7 @@ class QbitEmailTransportTest private void provider() throws QException { brand(); - SystemWrites.insertOne(EmailTables.PROVIDER, new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1") + insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1") .withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true)); } @@ -114,12 +117,23 @@ class QbitEmailTransportTest *******************************************************************************/ private void brand() throws QException { - SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Example").withValue("isDefault", true) + insert("email_brand", new QRecord().withValue("name", "default").withValue("label", "Example").withValue("isDefault", true) .withValue("postalAddress", "1 Main St, Springfield")); } + /******************************************************************************* + ** Configure the email qbit's editable tables through ordinary QQQ actions. + *******************************************************************************/ + private void insert(String table, QRecord record) throws QException + { + var output = new InsertAction().execute(new InsertInput(table).withRecords(List.of(record))); + assertThat(output.getRecords()).hasSize(1).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty()); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -151,22 +165,32 @@ class QbitEmailTransportTest assertThat(result.outcome()).as(result.message()).isEqualTo(TransportResult.Outcome.ACCEPTED); assertThat(result.providerStatus()).isEqualTo("queued"); - List rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()); + List rows = EmailApi.findMessagesByIdempotencyKeyPrefix("marketing:3:4:", 10); assertThat(rows).hasSize(2); - assertThat(rows).extracting(r -> r.getValueString("idempotencyKey")).containsExactlyInAnyOrder("marketing:3:4:crm-contact:1", "marketing:3:4:crm-contact:2"); + assertThat(rows).extracting(EmailApiMessage::idempotencyKey).containsExactlyInAnyOrder("marketing:3:4:crm-contact:1", "marketing:3:4:crm-contact:2"); assertThat(rows).allSatisfy(r -> { - assertThat(r.getValueString("category")).isEqualTo("MARKETING"); + assertThat(r.category()).isEqualTo(EmailApiCategory.MARKETING); + assertThat(r.linkedTableName()).isEqualTo("marketing_execution"); + assertThat(r.linkedRecordId()).isEqualTo("7"); + assertThat(r.templateName()).isNull(); + assertThat(r.status()).isEqualTo("QUEUED"); + }); + + // The public API intentionally omits content; inspect it as an email admin. + List messages = new QueryAction().execute(new QueryInput("email_message") + .withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false)).getRecords(); + assertThat(messages).hasSize(2).allSatisfy(r -> + { assertThat(r.getValueString("subject")).isEqualTo("Big news"); - assertThat(r.getValueString("linkedTableName")).isEqualTo("marketing_execution"); - assertThat(r.getValueString("linkedRecordId")).isEqualTo("7"); assertThat(r.getValueString("senderQbit")).isEqualTo("marketing"); + assertThat(r.getValueString("senderProcess")).isEqualTo("execution"); assertThat(r.getValueString("htmlBody")).contains("https://example.org/try").contains("nsubscribe"); assertThat(r.getValueString("toAddresses")).doesNotContain(","); }); assertThat(transport.send(request).outcome()).isEqualTo(TransportResult.Outcome.DUPLICATE); - assertThat(SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter())).hasSize(2); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("marketing:3:4:", 10)).hasSize(2); assertThat(transport.lookup("marketing-execution:3:4").state()).isEqualTo(MarketingTransport.TransportLookup.State.FOUND_ACCEPTED); assertThat(transport.lookup("marketing-execution:9:9").state()).isEqualTo(MarketingTransport.TransportLookup.State.NOT_FOUND); @@ -183,7 +207,7 @@ class QbitEmailTransportTest { instance(true, "https://app.example.org", "k", new MarketingQBitConfig()); provider(); - SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "gone@example.org").withValue("reason", "UNSUBSCRIBED").withValue("scope", "MARKETING")); + insert("email_suppression", new QRecord().withValue("address", "gone@example.org").withValue("reason", "UNSUBSCRIBED").withValue("scope", "MARKETING")); MarketingTransport transport = new QbitEmailTransport(); TransportResult result = transport.send(request("marketing-execution:1:1", new Recipient("crm-contact:9", "gone@example.org"))); @@ -216,6 +240,7 @@ class QbitEmailTransportTest assertThat(new QbitEmailTransport().getStatus().detail()).contains("publicBaseUrl"); instance(true, "https://app.example.org", "k", new MarketingQBitConfig()); + assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL); TransportResult refused = new QbitEmailTransport().send(request("marketing-execution:5:5", new Recipient("a", "a@example.org"))); assertThat(refused.outcome()).isEqualTo(TransportResult.Outcome.REJECTED); assertThat(refused.message()).contains("refused the message"); @@ -227,6 +252,25 @@ class QbitEmailTransportTest + /******************************************************************************* + ** A missing postal address is a send-time refusal, not NOT_CONFIGURED. + *******************************************************************************/ + @Test + void testMissingBrandIsRefusedAtSend() throws Exception + { + instance(true, "https://app.example.org", "k", new MarketingQBitConfig()); + insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1") + .withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true)); + + QbitEmailTransport transport = new QbitEmailTransport(); + assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL); + TransportResult result = transport.send(request("marketing-execution:8:8", new Recipient("a", "a@example.org"))); + assertThat(result.outcome()).isEqualTo(TransportResult.Outcome.REJECTED); + assertThat(result.message()).contains("postal address"); + } + + + /******************************************************************************* ** Without the email qbit in the instance the name does not resolve to the ** transport, and a host-registered transport always wins. -- 2.54.0