feat(email): QbitEmailTransport for MARKETING sends through qbit-email #11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user