diff --git a/AGENTS.md b/AGENTS.md index 31392e5..3a6017f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 031cb12..0522189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. + diff --git a/CLAUDE.md b/CLAUDE.md index 8751ae6..df1cead 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 27dfe27..3425df0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > Marketing for QQQ applications: products and their verified evidence, campaigns, content with revision history, exact human approval, controlled execution, honest measurement, and a marketing agent that prepares work but never publishes. **Status:** 0.1.0, not yet published. -- Built against QQQ `4.1.0-RC.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:::`; 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 | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b7bdcbc..a57754e 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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. diff --git a/pom.xml b/pom.xml index b727919..0cef9a0 100644 --- a/pom.xml +++ b/pom.xml @@ -26,6 +26,8 @@ 0.1.0-RC.2 + + 0.1.0-RC.1 2.3.232 @@ -83,6 +85,14 @@ ${qbit-assistant.version} + + + com.kingsrook.qbits + qbit-email + ${qbit-email.version} + true + + com.kingsrook.qqq @@ -177,14 +187,14 @@ - + qqq-snapshot true - 4.1.0-RC.4 + 4.1.0-RC.5 diff --git a/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java b/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java index c59ec83..e6c5b1f 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java +++ b/src/main/java/com/kingsrook/qbits/marketing/MarketingRuntime.java @@ -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 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java b/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java new file mode 100644 index 0000000..1b1b9b8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/marketing/email/QbitEmailTransport.java @@ -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 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 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:", "")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/marketing/MarketingQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/marketing/MarketingQBitProducerTest.java index 9af1aea..8379896 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/MarketingQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/MarketingQBitProducerTest.java @@ -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(); } diff --git a/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java b/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java new file mode 100644 index 0000000..fe9872a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/marketing/email/QbitEmailTransportTest.java @@ -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 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 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); + } + +}