feat(email): QbitEmailTransport for MARKETING sends through qbit-email #11

Merged
KofTwentyTwo merged 2 commits from feature/email-qbit into develop 2026-10-14 13:10:59 +00:00
10 changed files with 610 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ Services: `approval.ApprovalService`, `execution.ExecutionService`, `measurement
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+4
View File
@@ -42,3 +42,7 @@ All notable changes to this project are documented here. The format follows Keep
- Streamed-ETL record actions with standard summary lines.
- **Starter data:** QQQ, gclo and MarkdownViewr for Windows, verified 2026-10-12, with honest starter campaigns and the four channels.
- **Docs:** README, DESIGN, OPERATIONS and STARTER-DATA.
### Changed
- **Email:** new `QbitEmailTransport` sends campaign email through qbit-email (one MARKETING message per recipient, unsubscribe and suppression enforced there, idempotent, linked to the execution). It is selected automatically for the `qqq_email` channel when qbit-email is produced and the host registered no such transport; `QqqMessagingEmailTransport` remains the fallback. qbit-email is an optional dependency; QQQ moves to `4.1.0-RC.5`.
+1 -1
View File
@@ -46,7 +46,7 @@ Services: `approval.ApprovalService`, `execution.ExecutionService`, `measurement
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+12 -1
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.4`.
- Built against QQQ `4.1.0-RC.5`.
- 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).
@@ -83,6 +83,17 @@ Then:
- Content: Proposed Publication.
- **Actions:** every action is a streamed-ETL record action (record input, standard Review and Result summary lines) in the table's or record's Actions menu. None are nav items.
## Email through qbit-email
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is produced in the same instance, email channels named `qqq_email` (the starter-data channel) send through it automatically, unless the host registered its own transport under that name. `QqqMessagingEmailTransport` stays the fallback; without qbit-email nothing changes.
- Each recipient is one `MARKETING` message. qbit-email adds the RFC 8058 unsubscribe link and headers, enforces suppression and owns retries.
- The campaign content is sent as an ad-hoc Markdown email (subject = title, body plus the call to action link); no qbit-email template is registered.
- 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`.
## What is implemented, what is not configured
| Implemented and tested | Implemented, not configured / verified live |
+2 -1
View File
@@ -49,7 +49,8 @@ Nothing in Marketing stores a credential, and every key is read from the host's
|---|---|
| Claude | `ANTHROPIC_API_KEY` (or `AnthropicModelProvider.builder().apiKey(fromSecretStore)`), and a model id such as `claude-sonnet-5-5`. |
| OpenAI-compatible | A base URL (`https://api.openai.com/v1`, `http://localhost:11434/v1` for Ollama, ...), an API key reference such as `${env.MARKETING_OPENAI_API_KEY}` if the server needs one, and a model id. |
| Email | A QQQ messaging provider in the host instance (SMTP or SES), its name, and a from-address. After verifying it end to end, `withVerifiedOperational(true)`. Until then the email transport reports *Configured, not verified* and executions are blocked. |
| Email (qbit-email) | When qbit-email is produced in the instance and no transport named `qqq_email` is registered, email goes through its outbox (category MARKETING). It needs `publicBaseUrl`, the `QQQ_EMAIL_UNSUBSCRIBE_KEY` secret, an enabled default provider and a default brand with a postal address; a missing piece is reported as *Not configured* or as a plain rejected-execution reason. |
| Email (QQQ messaging) | A QQQ messaging provider in the host instance (SMTP or SES), its name, and a from-address. After verifying it end to end, `withVerifiedOperational(true)`. Until then the email transport reports *Configured, not verified* and executions are blocked. |
**Status:** check **Configuration Status** on the Marketing dashboard after any change.
+12 -2
View File
@@ -26,6 +26,8 @@
<!-- provider and the price table come from qbit-assistant; marketing -->
<!-- reuses them instead of a second provider abstraction. -->
<qbit-assistant.version>0.1.0-RC.2</qbit-assistant.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<h2.version>2.3.232</h2.version>
</properties>
@@ -83,6 +85,14 @@
<version>${qbit-assistant.version}</version>
</dependency>
<!-- optional: email through qbit-email when it is produced in the host instance (QbitEmailTransport) -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<!-- tests: the RDBMS backend over H2 proves the DDL and persistence across a restart -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -177,14 +187,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -29,9 +29,11 @@ import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway;
import com.kingsrook.qbits.marketing.email.QbitEmailTransport;
import com.kingsrook.qbits.marketing.model.MarketingPolicy;
import com.kingsrook.qbits.marketing.transport.ControlledTestTransport;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
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;
@@ -71,6 +73,7 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
private final ReentrantLock workerLock = new ReentrantLock();
private final Deque<Long> modelCallTimes = new ArrayDeque<>();
private final String workerName;
private volatile MarketingTransport qbitEmailTransport;
@@ -131,11 +134,47 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
/*******************************************************************************
** A transport by name, or null.
** A transport by name, or null. When the host registered no transport named
** qqq_email (the default email channel name) and qbit-email is produced in the
** instance, that name resolves to QbitEmailTransport; a host-registered
** transport always wins, and QqqMessagingEmailTransport remains the fallback
** the host can register.
*******************************************************************************/
public MarketingTransport getTransport(String name)
{
return (name == null ? null : transports.get(name));
if(name == null)
{
return (null);
}
MarketingTransport transport = transports.get(name);
if(transport == null && QqqMessagingEmailTransport.DEFAULT_NAME.equals(name) && emailQBitClassPresent() && QbitEmailTransport.isInstalled(QContext.getQInstance()))
{
if(qbitEmailTransport == null)
{
qbitEmailTransport = new QbitEmailTransport();
}
return (qbitEmailTransport);
}
return (transport);
}
/*******************************************************************************
** qbit-email is an optional dependency: touch QbitEmailTransport only when
** its classes are present.
*******************************************************************************/
private static boolean emailQBitClassPresent()
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
@@ -0,0 +1,289 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.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.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.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;
/*******************************************************************************
** Email through qbit-email: the durable outbox, the RFC 8058 unsubscribe
** link and headers, and suppression enforcement all live there.
**
** This is the ONLY class in this qbit that references qbit-email classes
** (the dependency is optional). Callers reach it only after
** isInstalled(...) is true; otherwise QqqMessagingEmailTransport is the
** fallback.
**
** Content: a campaign's content row holds a title and a plain body (no
** template), so each message is an ad-hoc Markdown email: subject = title,
** body = body plus the call to action as a link. No qbit-email template is
** registered (nothing here needs a variable schema).
**
** One message per recipient, category MARKETING, idempotency key
** marketing:CONTENT:APPROVAL:RECIPIENT-KEY (the execution's own stable key
** plus the recipient key, never an address), and the execution row as the
** linked record.
**
** Outcomes: QUEUED is ACCEPTED (in qbit-email's outbox, not delivered).
** SUPPRESSED is a skip, not a failure: the address opted out or bounced, so
** nothing is sent for it (the result says how many were skipped; if every
** recipient was suppressed the result is ACCEPTED with providerStatus
** all_suppressed). REFUSED is REJECTED with qbit-email's reasons. An
** exception is AMBIGUOUS, reconcilable through lookup (the idempotency key
** means a repeat can never send twice).
*******************************************************************************/
public class QbitEmailTransport implements MarketingTransport
{
private static final QLogger LOG = QLogger.getLogger(QbitEmailTransport.class);
public static final String DEFAULT_NAME = "qbit_email";
private static final String UNSUBSCRIBE_KEY_NAME = "QQQ_EMAIL_UNSUBSCRIBE_KEY";
private String name = DEFAULT_NAME;
/*******************************************************************************
** Is qbit-email on the classpath AND produced in this instance?
*******************************************************************************/
public static boolean isInstalled(QInstance qInstance)
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
return (EmailRuntime.config(qInstance) != null);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (name);
}
/*******************************************************************************
** OPERATIONAL when the qbit-email outbox is produced and marketing's opt-out
** prerequisites (public base URL and unsubscribe key) are configured; the
** provider itself is checked by qbit-email at send time and a problem there
** comes back as a plain REJECTED reason.
*******************************************************************************/
@Override
public TransportStatus getStatus()
{
EmailQBitConfig emailConfig = isInstalled(QContext.getQInstance()) ? EmailRuntime.config() : null;
if(emailConfig == null)
{
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);
}
catch(Exception 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)."));
}
return (new TransportStatus(TransportStatus.State.OPERATIONAL, "Queues through the email qbit's outbox with its unsubscribe link and suppression list."));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public Set<ChannelType> getSupportedChannelTypes()
{
return (EnumSet.of(ChannelType.EMAIL));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public TransportResult send(TransportRequest request)
{
TransportStatus status = getStatus();
if(!status.isUsable())
{
return (new TransportResult(TransportResult.Outcome.RETRYABLE_FAILURE, null, "not_configured", status.detail()));
}
int queued = 0;
int duplicates = 0;
int suppressed = 0;
for(Recipient recipient : request.recipients())
{
if(!StringUtils.hasContent(recipient.address()))
{
return (new TransportResult(queued + duplicates == 0 ? TransportResult.Outcome.REJECTED : TransportResult.Outcome.AMBIGUOUS, null, "no_address",
"A recipient has no address" + (queued + duplicates == 0 ? "." : " after " + (queued + duplicates) + " message(s) were queued.")));
}
try
{
EmailSendResult result = EmailService.send(emailRequest(request, recipient));
switch(result.status())
{
case QUEUED -> queued++;
case DUPLICATE -> duplicates++;
case SUPPRESSED -> suppressed++;
default ->
{
String reasons = result.reasons() == null || result.reasons().isEmpty() ? "no reason given" : String.join("; ", result.reasons());
LOG.info("Email qbit refused a marketing message", logPair("executionId", request.executionId()), logPair("recipientKey", recipient.key()));
return (new TransportResult(TransportResult.Outcome.REJECTED, null, "refused",
"The email qbit refused the message: " + reasons + " (" + queued + " queued and " + suppressed + " suppressed before it)."));
}
}
}
catch(Exception e)
{
LOG.warn("Email qbit enqueue failed; outcome unknown", logPair("executionId", request.executionId()), logPair("recipientKey", recipient.key()),
logPair("error", e.getClass().getSimpleName()));
return (new TransportResult(TransportResult.Outcome.AMBIGUOUS, null, "error",
"The email qbit raised " + e.getClass().getSimpleName() + " after " + (queued + duplicates) + " of " + request.recipients().size()
+ " message(s); a repeat cannot send twice, and lookup can confirm what was queued."));
}
}
String receipt = "qbit-email:" + baseKey(request);
String summary = queued + " queued, " + suppressed + " skipped as suppressed, " + duplicates + " already queued (queued is not proof of delivery).";
if(queued == 0 && suppressed == 0)
{
return (new TransportResult(TransportResult.Outcome.DUPLICATE, receipt, "already_queued", summary));
}
return (new TransportResult(TransportResult.Outcome.ACCEPTED, receipt, queued == 0 ? "all_suppressed" : "queued", summary));
}
/*******************************************************************************
** Finds what qbit-email queued for the execution (any message under its key).
*******************************************************************************/
@Override
public TransportLookup lookup(String idempotencyKey)
{
try
{
String prefix = "marketing:" + stripPrefix(idempotencyKey) + ":";
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, prefix)).withLimit(1));
if(rows.isEmpty())
{
return (new TransportLookup(TransportLookup.State.NOT_FOUND, null));
}
return (new TransportLookup(TransportLookup.State.FOUND_ACCEPTED, "qbit-email:marketing:" + stripPrefix(idempotencyKey)));
}
catch(Exception e)
{
LOG.warn("Email qbit lookup failed", logPair("error", e.getClass().getSimpleName()));
return (new TransportLookup(TransportLookup.State.UNSUPPORTED, null));
}
}
/*******************************************************************************
** One MARKETING message to one recipient.
*******************************************************************************/
private EmailRequest emailRequest(TransportRequest request, Recipient recipient)
{
String body = request.body() == null ? "" : request.body();
if(StringUtils.hasContent(request.destinationUrl()) && request.destinationUrl().matches("(?i)https?://\\S+"))
{
String label = StringUtils.hasContent(request.callToAction()) ? request.callToAction().replaceAll("[\\[\\]\\r\\n]", " ").trim() : request.destinationUrl();
body = body + "\n\n[" + label + "](" + request.destinationUrl() + ")";
}
return (new EmailRequest()
.withTo(recipient.address())
.withSubject(request.title() == null ? "" : request.title().replaceAll("[\\r\\n]+", " ").trim())
.withMarkdownBody(body)
.withCategory(EmailCategory.MARKETING)
.withIdempotencyKey(baseKey(request) + ":" + recipient.key())
.withLinkedRecord(MarketingExecution.TABLE_NAME, request.executionId())
.withSender("marketing", "execution"));
}
/*******************************************************************************
** The per-execution part of the idempotency key.
*******************************************************************************/
private String baseKey(TransportRequest request)
{
return ("marketing:" + stripPrefix(request.idempotencyKey()));
}
/*******************************************************************************
** The execution key is marketing-execution:CONTENT:APPROVAL.
*******************************************************************************/
private static String stripPrefix(String key)
{
return (key == null ? "" : key.replaceFirst("^marketing-execution:", ""));
}
}
@@ -111,12 +111,11 @@ class MarketingQBitProducerTest extends BaseMarketingTest
/*******************************************************************************
** On QQQ 4.1.0-RC.4 the kernel hook is absent; the qbit still produces.
** Whether or not the kernel hook is available (absent on QQQ 4.1.0-RC.4), registering no tools registers nothing.
*******************************************************************************/
@Test
void testKernelHookAbsentOnRc4()
void testKernelHookRegistersNothingWithoutTools()
{
assertThat(KernelToolRegistration.isAvailable()).isFalse();
assertThat(KernelToolRegistration.register(qInstance, List.of())).isZero();
}
@@ -0,0 +1,246 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.marketing.email;
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.marketing.MarketingQBitConfig;
import com.kingsrook.qbits.marketing.MarketingQBitProducer;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
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.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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** QbitEmailTransport against a real qbit-email outbox (memory backend).
*******************************************************************************/
class QbitEmailTransportTest
{
private static final String BACKEND = "memory";
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
** An instance with marketing and (optionally) the email qbit.
*******************************************************************************/
private QInstance instance(boolean withEmail, String publicBaseUrl, String unsubscribeKey, MarketingQBitConfig marketingConfig) throws Exception
{
MemoryRecordStore.getInstance().reset();
QInstance qInstance = new QInstance();
qInstance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class));
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
if(withEmail)
{
EmailQBitConfig emailConfig = new EmailQBitConfig().withBackendName(BACKEND).withPublicBaseUrl(publicBaseUrl)
.withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? unsubscribeKey : null);
new EmailQBitProducer().withQBitConfig(emailConfig).produce(qInstance);
}
new MarketingQBitProducer().withQBitConfig(marketingConfig.withBackendName(BACKEND)).produce(qInstance);
new QInstanceValidator().validate(qInstance);
List<String> permissions = new ArrayList<>(MarketingPermissions.ALL);
permissions.addAll(EmailPermissions.ALL);
QContext.init(qInstance, new QSession().withPermissions(permissions));
return (qInstance);
}
/*******************************************************************************
**
*******************************************************************************/
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")
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
}
/*******************************************************************************
** Marketing email needs a default brand with a postal address.
*******************************************************************************/
private void brand() throws QException
{
SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Example").withValue("isDefault", true)
.withValue("postalAddress", "1 Main St, Springfield"));
}
/*******************************************************************************
**
*******************************************************************************/
private TransportRequest request(String key, Recipient... recipients)
{
return (new TransportRequest(key, 7, 1, "Email", ChannelType.EMAIL, "Big news", "Hello **there**.", "https://example.org/try", "Try it", List.of(recipients)));
}
/*******************************************************************************
** One MARKETING message per recipient with the right key and link; a repeat
** is DUPLICATE; the qqq_email name resolves to the transport.
*******************************************************************************/
@Test
void testSendsOneMarketingMessagePerRecipient() throws Exception
{
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
provider();
MarketingTransport transport = MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME);
assertThat(transport).isInstanceOf(QbitEmailTransport.class);
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.OPERATIONAL);
assertThat(transport.getSupportedChannelTypes()).containsExactly(ChannelType.EMAIL);
TransportRequest request = request("marketing-execution:3:4", new Recipient("crm-contact:1", "a@example.org"), new Recipient("crm-contact:2", "b@example.org"));
TransportResult result = transport.send(request);
assertThat(result.message()).as(result.message()).isNotNull();
assertThat(result.outcome()).as(result.message()).isEqualTo(TransportResult.Outcome.ACCEPTED);
assertThat(result.providerStatus()).isEqualTo("queued");
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter());
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).allSatisfy(r ->
{
assertThat(r.getValueString("category")).isEqualTo("MARKETING");
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("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(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);
}
/*******************************************************************************
** A suppressed address is skipped, not failed; with every recipient
** suppressed the result is still ACCEPTED (all_suppressed).
*******************************************************************************/
@Test
void testSuppressedIsSkipped() throws Exception
{
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"));
MarketingTransport transport = new QbitEmailTransport();
TransportResult result = transport.send(request("marketing-execution:1:1", new Recipient("crm-contact:9", "gone@example.org")));
assertThat(result.outcome()).isEqualTo(TransportResult.Outcome.ACCEPTED);
assertThat(result.providerStatus()).isEqualTo("all_suppressed");
assertThat(result.message()).contains("1 skipped as suppressed");
TransportResult mixed = transport.send(request("marketing-execution:2:1", new Recipient("crm-contact:9", "gone@example.org"), new Recipient("crm-contact:10", "ok@example.org")));
assertThat(mixed.providerStatus()).isEqualTo("queued");
assertThat(mixed.message()).contains("1 queued, 1 skipped");
}
/*******************************************************************************
** Without the unsubscribe key or public base URL the transport is not
** configured and says why; a refusal by the email qbit is REJECTED with
** its reason.
*******************************************************************************/
@Test
void testNotConfiguredAndRefused() throws Exception
{
instance(true, "https://app.example.org", null, new MarketingQBitConfig());
QbitEmailTransport transport = new QbitEmailTransport();
assertThat(transport.getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.NOT_CONFIGURED);
assertThat(transport.getStatus().detail()).contains("QQQ_EMAIL_UNSUBSCRIBE_KEY");
assertThat(transport.send(request("k", new Recipient("a", "a@example.org"))).outcome()).isEqualTo(TransportResult.Outcome.RETRYABLE_FAILURE);
instance(true, null, "k", new MarketingQBitConfig());
assertThat(new QbitEmailTransport().getStatus().detail()).contains("publicBaseUrl");
instance(true, "https://app.example.org", "k", new MarketingQBitConfig());
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");
provider();
TransportResult noAddress = new QbitEmailTransport().send(request("marketing-execution:6:6", new Recipient("a", null)));
assertThat(noAddress.outcome()).isEqualTo(TransportResult.Outcome.REJECTED);
}
/*******************************************************************************
** Without the email qbit in the instance the name does not resolve to the
** transport, and a host-registered transport always wins.
*******************************************************************************/
@Test
void testFallbackAndHostTransportWin() throws Exception
{
instance(false, null, null, new MarketingQBitConfig());
assertThat(MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME)).isNull();
assertThat(new QbitEmailTransport().getStatus().state()).isEqualTo(MarketingTransport.TransportStatus.State.NOT_CONFIGURED);
QqqMessagingEmailTransport host = new QqqMessagingEmailTransport().withMessagingProviderName("email").withFromAddress("n@example.org");
instance(true, "https://app.example.org", "k", new MarketingQBitConfig().withTransport(host));
assertThat(MarketingRuntime.current().getTransport(QqqMessagingEmailTransport.DEFAULT_NAME)).isSameAs(host);
}
}