Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 78ad854f41 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 <noreply@anthropic.com>
2026-10-14 10:30:56 -05:00
james.maes 62e12e9ebe Merge pull request #12 from QRun-IO/feature/arch-provider-api
refactor(providers): depend only on qbit-assistant-provider-api (#9)
2026-10-14 08:27:24 -05:00
6 changed files with 102 additions and 59 deletions

No files matched your search

+2 -1
View File
@@ -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.
+2 -2
View File
@@ -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:<content id>:<approval id>:<recipient key>`; 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
+6 -6
View File
@@ -26,19 +26,19 @@
<qbit-assistant.version>0.1.0-RC.11</qbit-assistant.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<!-- 2.1.0 matches package prefixes, not class names; provider-api owns ModelPricing. -->
<!-- TEMPORARY: qbit-email internals until it publishes an API (QRun-IO/qbit-marketing#13). -->
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.assistant.provider,com.kingsrook.qbits.assistant.engine,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-assistant-provider-openai,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<!-- Assistant provider SPI only (not email): these replace the parent lists, so they repeat -->
<!-- the parent defaults (object storage, qbit-email's api, *-api) plus the assistant entries. -->
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.assistant.provider,com.kingsrook.qbits.assistant.engine</qbit.guardrails.allowedApiPackages>
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-assistant-provider-openai</qbit.guardrails.allowedQbitArtifacts>
<qbit-email.version>0.1.0-RC.3</qbit-email.version>
<h2.version>2.3.232</h2.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -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)
@@ -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<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, prefix)).withLimit(1));
String prefix = "marketing:" + stripPrefix(idempotencyKey) + ":";
List<EmailApiMessage> 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"));
@@ -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<String> 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<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter());
List<EmailApiMessage> 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<QRecord> 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.