From 45593d2e796f1b2c8ad41625eeeebaf44142da9d Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:12:37 -0500 Subject: [PATCH 1/2] feat(email): scheduled-result email adapter for qbit-email Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an optional dependency (0.1.0-SNAPSHOT until its first RC) used through one adapter class behind a classpath and instance guard; without it the existing behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 20 ++ pom.xml | 2 + qbit-assistant/pom.xml | 10 + .../qbits/assistant/AssistantQBitConfig.java | 58 ++++ .../qbits/assistant/email/AssistantEmail.java | 111 ++++++++ .../assistant/email/AssistantEmailSender.java | 158 +++++++++++ .../assistant/email/ScheduledEmailStatus.java | 38 +++ .../email/ScheduledResultNotice.java | 46 ++++ .../assistant/email/AssistantEmailTest.java | 249 ++++++++++++++++++ 10 files changed, 698 insertions(+) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmail.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmailSender.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledEmailStatus.java create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledResultNotice.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/email/AssistantEmailTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd94ca..a940267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased - Email + +### Changed + +- Add optional qbit-email delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC, owner-recipient hook, opt-in and `emailExternalApproved` gates. The scheduled-task runner itself is still P-g. + ## Unreleased - P-f - Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING diff --git a/README.md b/README.md index ad5a707..8e0368b 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ The nav lists the home and the main tables: **Providers**, **Conversations**, ** | `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. | | `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. | | `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. | +| `emailExternalApproved` | `false` | Stands in for the EXTERNAL approval rule (not built yet): scheduled-task result email is sent only when `true`. | +| `emailRecipientResolver` | owner id if it is an address | Hook: task owner user id to email address. | At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row. @@ -446,6 +448,24 @@ engine/tool golden turns. No cloud credentials or live endpoints are required. Apache 2.0. +## Email through qbit-email + +When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`. + +Scheduled tasks themselves (P-g) are not built yet, so there is no table or runner to wire. The runner will call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after each run. A task needs an `email_result` flag (add it to the scheduled-task table and its DDL/changeset with the table; the runner passes it as `emailResult`). + +Gates, in order (each returns a status and queues nothing): +1. the task opted in (`NOT_REQUESTED`); +2. qbit-email is installed (`NOT_AVAILABLE`); +3. `emailExternalApproved` is `true` (`NOT_APPROVED`). The EXTERNAL approval rule exists only in the spec, so this explicit flag gates it until the rule is built; +4. a recipient resolves (`NO_RECIPIENT`): `emailRecipientResolver` maps the owner's user id to an address; by default an owner id that already is an address is used. + +What is sent, one message per recipient, idempotency key `assistant:scheduled:`, linked record the run: +- result classified PUBLIC (or none): template `assistant_scheduled_result` (NOTIFICATION, Markdown, registered on first send and never overwritten) with the task name, run time, a summary cut to 280 characters and a link to the run. The full model output is never emailed; +- result classified above PUBLIC (the `DataClassification` stand-in): only the link, through qbit-email's `email_secure_message` pattern. No task name, summary or output. + +The outbox result maps to `QUEUED`, `DUPLICATE`, `SUPPRESSED` or `REFUSED` (for example a link that is not absolute http(s)). Addresses and bodies are never logged. + ## Data classification and HIPAA mode On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration: diff --git a/pom.xml b/pom.xml index 173f755..a2b7bae 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,8 @@ 2.71.0 1.5.1 2.21.7 + + 0.1.0-SNAPSHOT live diff --git a/qbit-assistant/pom.xml b/qbit-assistant/pom.xml index a8a4acd..e066d69 100644 --- a/qbit-assistant/pom.xml +++ b/qbit-assistant/pom.xml @@ -45,6 +45,16 @@ true + + + + + com.kingsrook.qbits + qbit-email + ${qbit-email.version} + true + + org.junit.jupiter junit-jupiter diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java index a2af573..011cbfc 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java @@ -141,11 +141,13 @@ public class AssistantQBitConfig implements QBitConfig private Integer providerTestTimeoutSeconds = 30; private Integer homeMaxRows = 20000; private String tablePathPrefix = "/app"; + private Boolean emailExternalApproved = false; private transient MetaDataCustomizerInterface tableMetaDataCustomizer; private transient Map modelProviders = new LinkedHashMap<>(); private transient Map providerTypes = new LinkedHashMap<>(); private transient Function secretResolver = System::getenv; + private transient Function emailRecipientResolver; private transient AssistantRuntime runtime; @@ -1459,6 +1461,62 @@ public class AssistantQBitConfig implements QBitConfig + /******************************************************************************* + ** Whether the host's EXTERNAL approval rule allows a scheduled task's result + ** to leave the app by email. There is no approval rule in the qbit yet (the + ** scheduled runner is P-g), so this explicit flag stands in; default false. + *******************************************************************************/ + public Boolean getEmailExternalApproved() + { + return (this.emailExternalApproved); + } + + + + /******************************************************************************* + ** Setter for emailExternalApproved + *******************************************************************************/ + public void setEmailExternalApproved(Boolean emailExternalApproved) + { + this.emailExternalApproved = emailExternalApproved; + } + + + + /******************************************************************************* + ** Fluent setter for emailExternalApproved + *******************************************************************************/ + public AssistantQBitConfig withEmailExternalApproved(Boolean emailExternalApproved) + { + this.emailExternalApproved = emailExternalApproved; + return (this); + } + + + + /******************************************************************************* + ** The hook that turns a task owner's user id into an email address; null + ** means the default (an owner id that already is an address). + *******************************************************************************/ + @JsonIgnore + public Function getEmailRecipientResolver() + { + return (emailRecipientResolver); + } + + + + /******************************************************************************* + ** Fluent setter for emailRecipientResolver + *******************************************************************************/ + public AssistantQBitConfig withEmailRecipientResolver(Function emailRecipientResolver) + { + this.emailRecipientResolver = emailRecipientResolver; + return (this); + } + + + /******************************************************************************* ** Getter for allowedApiKeyEnvNames (extra API key environment variable names a provider row may reference) *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmail.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmail.java new file mode 100644 index 0000000..6e8841c --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmail.java @@ -0,0 +1,111 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.assistant.email; + + +import com.kingsrook.qbits.assistant.AssistantQBitConfig; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** Email delivery of a scheduled task's result, when the optional qbit-email + ** is present. This class names no qbit-email type, so it loads without the + ** jar; the adapter (AssistantEmailSender) is only loaded after the guard passes. + ** + ** The scheduled-task runner (P-g) is not built yet. It calls + ** sendScheduledResult after a run, and the gates below apply to every call: + ** the task opted in, the host approved EXTERNAL delivery + ** (AssistantQBitConfig.getEmailExternalApproved, until the approval rule + ** exists), and there is a recipient address. + *******************************************************************************/ +public final class AssistantEmail +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AssistantEmail() + { + } + + + + /******************************************************************************* + ** Whether qbit-email is on the classpath and produced in this instance. + *******************************************************************************/ + public static boolean isAvailable(QInstance qInstance) + { + try + { + Class.forName("com.kingsrook.qbits.email.outbox.EmailService"); + } + catch(ClassNotFoundException | LinkageError e) + { + return (false); + } + return (AssistantEmailSender.isInstalled(qInstance)); + } + + + + /******************************************************************************* + ** Email a finished scheduled run to its owner, if the task opted in and + ** every gate allows it. A missing email qbit is not an error; the status + ** says what happened. + *******************************************************************************/ + public static ScheduledEmailStatus sendScheduledResult(QInstance qInstance, AssistantQBitConfig config, ScheduledResultNotice notice) throws QException + { + if(notice == null || !notice.emailResult()) + { + return (ScheduledEmailStatus.NOT_REQUESTED); + } + if(!isAvailable(qInstance)) + { + return (ScheduledEmailStatus.NOT_AVAILABLE); + } + if(!Boolean.TRUE.equals(config.getEmailExternalApproved())) + { + return (ScheduledEmailStatus.NOT_APPROVED); + } + String recipient = recipient(config, notice.ownerUserId()); + if(recipient == null) + { + return (ScheduledEmailStatus.NO_RECIPIENT); + } + return (AssistantEmailSender.send(notice, recipient)); + } + + + + /******************************************************************************* + ** The owner's address: the host's resolver, else the owner id when it is + ** itself an address. Null when there is none. + *******************************************************************************/ + static String recipient(AssistantQBitConfig config, String ownerUserId) + { + if(ownerUserId == null || ownerUserId.isBlank()) + { + return (null); + } + String address = config.getEmailRecipientResolver() != null ? config.getEmailRecipientResolver().apply(ownerUserId) : (ownerUserId.contains("@") ? ownerUserId : null); + return (address == null || address.isBlank() ? null : address.trim()); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmailSender.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmailSender.java new file mode 100644 index 0000000..2aa5562 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/AssistantEmailSender.java @@ -0,0 +1,158 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.assistant.email; + + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.assistant.classification.DataClassification; +import com.kingsrook.qbits.email.EmailRuntime; +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.template.EmailTemplateDefinition; +import com.kingsrook.qbits.email.template.EmailTemplates; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** The only class that touches qbit-email. Registers the scheduled-result + ** template on first use (never overwriting edits) and queues one message per + ** recipient. Addresses, summaries and bodies are never logged. + *******************************************************************************/ +final class AssistantEmailSender +{ + static final String TEMPLATE_NAME = "assistant_scheduled_result"; + static final String SECURE_TEMPLATE = "email_secure_message"; + static final String SENDER_QBIT = "assistant"; + static final String SENDER_PROCESS = "assistantScheduledTask"; + + private static final int MAX_SUMMARY = 280; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AssistantEmailSender() + { + } + + + + /******************************************************************************* + ** Whether the email qbit is produced in this instance. + *******************************************************************************/ + static boolean isInstalled(QInstance qInstance) + { + return (EmailRuntime.config(qInstance) != null); + } + + + + /******************************************************************************* + ** Queue one message to one recipient. A result classified above PUBLIC + ** sends only the link, with the secure-message pattern. + *******************************************************************************/ + static ScheduledEmailStatus send(ScheduledResultNotice notice, String recipient) throws QException + { + boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL); + + EmailRequest request = new EmailRequest().withTo(recipient) + .withIdempotencyKey("assistant:scheduled:" + notice.runId()) + .withLinkedRecord(notice.runTable(), notice.runId()) + .withSender(SENDER_QBIT, SENDER_PROCESS); + + Map variables = new LinkedHashMap<>(); + variables.put("link", notice.link()); + if(linkOnly) + { + request.withTemplateName(SECURE_TEMPLATE); + } + else + { + ensureTemplate(); + request.withTemplateName(TEMPLATE_NAME); + variables.put("taskName", notice.taskName()); + variables.put("runTime", notice.runTime() == null ? null : notice.runTime().toString()); + variables.put("summary", summary(notice.summary())); + } + request.withVariables(variables); + + return (map(EmailService.send(request))); + } + + + + /******************************************************************************* + ** One line, cut short; the full model output never goes in an email. + *******************************************************************************/ + static String summary(String value) + { + if(value == null) + { + return (null); + } + String oneLine = value.replaceAll("\\s+", " ").trim(); + return (oneLine.length() <= MAX_SUMMARY ? oneLine : oneLine.substring(0, MAX_SUMMARY - 3) + "..."); + } + + + + /******************************************************************************* + ** Outbox result to the qbit's own vocabulary. + *******************************************************************************/ + private static ScheduledEmailStatus map(EmailSendResult result) + { + return (switch(result.status()) + { + case QUEUED -> ScheduledEmailStatus.QUEUED; + case DUPLICATE -> ScheduledEmailStatus.DUPLICATE; + case SUPPRESSED -> ScheduledEmailStatus.SUPPRESSED; + case REFUSED -> ScheduledEmailStatus.REFUSED; + }); + } + + + + /******************************************************************************* + ** Register the NOTIFICATION template once; an existing template is left alone. + *******************************************************************************/ + static void ensureTemplate() throws QException + { + EmailTemplates.ensure(new EmailTemplateDefinition() + .withName(TEMPLATE_NAME) + .withLabel("Assistant scheduled task result") + .withCategory(EmailCategory.NOTIFICATION) + .withKind("MARKDOWN") + .withOwnerQbit(SENDER_QBIT) + .withSubject("Assistant task result: {{taskName}}") + .withBody("Your scheduled assistant task **{{taskName}}** finished at {{runTime}}.\n\n{{summary}}\n\n[Open the run]({{link}}){.button}") + .withVariable("taskName", "Task name", "STRING", true, "CONFIDENTIAL") + .withVariable("runTime", "Run time", "DATE_TIME", true, "PUBLIC") + .withVariable("summary", "Short summary", "STRING", false, "CONFIDENTIAL") + .withVariable("link", "Link to the run", "URL", true, "PUBLIC") + .withSampleVariables("{\"taskName\":\"Weekly review\",\"runTime\":\"2026-10-12T09:00:00Z\",\"summary\":\"Three items need attention.\",\"link\":\"https://app.example.com/app/assistant_run/1\"}")); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledEmailStatus.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledEmailStatus.java new file mode 100644 index 0000000..f3087e0 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledEmailStatus.java @@ -0,0 +1,38 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.assistant.email; + + +/******************************************************************************* + ** Outcome of trying to email a scheduled task's result. The first four mean + ** nothing was queued and why; the rest mirror the outbox result. + *******************************************************************************/ +public enum ScheduledEmailStatus +{ + NOT_REQUESTED, + NOT_AVAILABLE, + NOT_APPROVED, + NO_RECIPIENT, + QUEUED, + DUPLICATE, + SUPPRESSED, + REFUSED +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledResultNotice.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledResultNotice.java new file mode 100644 index 0000000..64f6130 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/email/ScheduledResultNotice.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.assistant.email; + + +import java.time.Instant; +import com.kingsrook.qbits.assistant.classification.DataClassification; + + +/******************************************************************************* + ** What the scheduled-task runner knows about one finished run, for the + ** result email. The full model output is deliberately not part of it: only a + ** short summary, and only when the result is classified PUBLIC. + ** + ** @param runTable the table holding the run record (the email's linked record) + ** @param runId the run's id (also the idempotency key) + ** @param taskName the scheduled task's name + ** @param runTime when the run finished + ** @param summary a short summary (cut to 280 characters; never the model output) + ** @param classification the result's classification; null means PUBLIC + ** @param ownerUserId the task owner's user id (the way the qbit identifies users) + ** @param emailResult whether the task opted in to email delivery + ** @param link absolute link to the run record in the app + *******************************************************************************/ +public record ScheduledResultNotice(String runTable, Object runId, String taskName, Instant runTime, String summary, DataClassification classification, + String ownerUserId, boolean emailResult, String link) +{ +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/email/AssistantEmailTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/email/AssistantEmailTest.java new file mode 100644 index 0000000..a3720fa --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/email/AssistantEmailTest.java @@ -0,0 +1,249 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.assistant.email; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.assistant.AssistantQBitConfig; +import com.kingsrook.qbits.assistant.classification.DataClassification; +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.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Scheduled-result email through qbit-email, with the email qbit produced on a + ** memory-backend instance, and the behavior when it is not. + *******************************************************************************/ +class AssistantEmailTest +{ + private static final String RUN_TABLE = "assistant_scheduled_run"; + + private QInstance instance; + private AssistantQBitConfig config; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + instance = newInstance(true); + config = new AssistantQBitConfig().withEmailExternalApproved(true); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** A memory-backend instance, with or without the email qbit. + *******************************************************************************/ + private static QInstance newInstance(boolean withEmail) throws Exception + { + MemoryRecordStore.fullReset(); + QContext.clear(); + QInstance qInstance = new QInstance(); + qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + if(withEmail) + { + new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.com") + .withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? "test-unsubscribe-key" : null)).produce(qInstance); + } + if(withEmail) + { + QContext.init(qInstance, new QSession().withPermissions(EmailPermissions.ALL)); + new InsertAction().execute(new InsertInput(EmailTables.PROVIDER).withRecords(List.of(new QRecord().withValue("name", "relay").withValue("label", "Relay") + .withValue("kind", "SMTP").withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE") + .withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true)))); + } + return (qInstance); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private ScheduledResultNotice notice(int runId, String summary, DataClassification classification, boolean optIn) + { + return (new ScheduledResultNotice(RUN_TABLE, runId, "Weekly review", Instant.parse("2026-10-12T09:00:00Z"), summary, classification, + "owner@example.com", optIn, "https://app.example.com/app/" + RUN_TABLE + "/" + runId)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List messages() throws Exception + { + return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter())); + } + + + + /******************************************************************************* + ** A PUBLIC result queues one NOTIFICATION message from the template, with + ** the idempotency key and the linked run; a repeat is a duplicate. + *******************************************************************************/ + @Test + void publicResultQueuesTemplateMessage() throws Exception + { + assertThat(AssistantEmail.isAvailable(instance)).isTrue(); + assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.QUEUED); + + List messages = messages(); + assertThat(messages).hasSize(1); + QRecord message = messages.get(0); + assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(message.getValueString("toAddresses")).isEqualTo("owner@example.com"); + assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:7"); + assertThat(message.getValueString("linkedTableName")).isEqualTo(RUN_TABLE); + assertThat(message.getValueString("linkedRecordId")).isEqualTo("7"); + assertThat(message.getValueString("senderQbit")).isEqualTo("assistant"); + assertThat(message.getValueString("subject")).contains("Weekly review"); + assertThat(message.getValueString("htmlBody")).contains("Three items need attention.").contains("https://app.example.com/app/assistant_scheduled_run/7"); + QRecord template = SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter()).stream().filter(t -> "assistant_scheduled_result".equals(t.getValueString("name"))).findFirst().orElseThrow(); + assertThat(template.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(template.getValueString("ownerQbit")).isEqualTo("assistant"); + assertThat(message.getValueInteger("templateId")).isEqualTo(template.getValueInteger("id")); + + assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.DUPLICATE); + assertThat(messages()).hasSize(1); + } + + + + /******************************************************************************* + ** Above PUBLIC, only the link goes out, with the secure-message pattern; + ** the summary and task name are not in the message. + *******************************************************************************/ + @Test + void classifiedResultSendsLinkOnly() throws Exception + { + assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(8, "Patient Jane Doe has diabetes", DataClassification.PHI, true))).isEqualTo(ScheduledEmailStatus.QUEUED); + + QRecord message = messages().get(0); + assertThat(message.getValueString("category")).isEqualTo("SECURITY"); + assertThat(message.getValueString("htmlBody")).contains("https://app.example.com/app/assistant_scheduled_run/8").doesNotContain("Jane Doe").doesNotContain("Weekly review"); + assertThat(message.getValueString("textBody")).doesNotContain("Jane Doe"); + assertThat(message.getValueString("subject")).doesNotContain("Weekly review"); + assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:8"); + assertThat(message.getValueString("linkedRecordId")).isEqualTo("8"); + } + + + + /******************************************************************************* + ** Each gate on its own: opt-in, the external approval, a recipient. + *******************************************************************************/ + @Test + void gates() throws Exception + { + assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(1, "x", null, false))).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED); + assertThat(AssistantEmail.sendScheduledResult(instance, config, null)).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED); + assertThat(AssistantEmail.sendScheduledResult(instance, new AssistantQBitConfig(), notice(2, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_APPROVED); + + ScheduledResultNotice noAddress = new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, "user-17", true, "https://app.example.com/app/x/3"); + assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT); + assertThat(AssistantEmail.sendScheduledResult(instance, config, new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, null, true, "https://app.example.com/a"))).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT); + assertThat(messages()).isEmpty(); + + config.withEmailRecipientResolver(id -> "user-17".equals(id) ? " user17@example.com " : null); + assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.QUEUED); + assertThat(messages().get(0).getValueString("toAddresses")).isEqualTo("user17@example.com"); + } + + + + /******************************************************************************* + ** The outbox refusing the message (here a relative link) is reported, not thrown. + *******************************************************************************/ + @Test + void refusedIsReported() throws Exception + { + ScheduledResultNotice relative = new ScheduledResultNotice(RUN_TABLE, 4, "t", Instant.now(), "x", null, "owner@example.com", true, "/app/x/4"); + assertThat(AssistantEmail.sendScheduledResult(instance, config, relative)).isEqualTo(ScheduledEmailStatus.REFUSED); + assertThat(messages()).isEmpty(); + } + + + + /******************************************************************************* + ** The summary is one line and cut short. + *******************************************************************************/ + @Test + void summaryIsShort() throws Exception + { + assertThat(AssistantEmailSender.summary(null)).isNull(); + assertThat(AssistantEmailSender.summary("a\n\n b")).isEqualTo("a b"); + assertThat(AssistantEmailSender.summary("x".repeat(1000))).hasSize(280).endsWith("..."); + + AssistantEmail.sendScheduledResult(instance, config, notice(5, "y".repeat(2000), null, true)); + assertThat(messages().get(0).getValueString("htmlBody")).doesNotContain("y".repeat(281)); + } + + + + /******************************************************************************* + ** Without the email qbit in the instance nothing is queued and nothing breaks. + *******************************************************************************/ + @Test + void withoutEmailQbit() throws Exception + { + QInstance bare = newInstance(false); + assertThat(AssistantEmail.isAvailable(bare)).isFalse(); + assertThat(AssistantEmail.isAvailable(null)).isFalse(); + assertThat(AssistantEmail.sendScheduledResult(bare, config, notice(6, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_AVAILABLE); + } +} -- 2.54.0 From c40eebd3cdadd6f29437a92ce65ad0fbfdc9a823 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:58:48 -0500 Subject: [PATCH 2/2] build: qbit-email 0.1.0-RC.1 from Maven Central Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas gain the RC's email_provider_sender_address table and the message from_address_id column where they copy the qbit's DDL. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a2b7bae..06e6432 100644 --- a/pom.xml +++ b/pom.xml @@ -28,8 +28,8 @@ 2.71.0 1.5.1 2.21.7 - - 0.1.0-SNAPSHOT + + 0.1.0-RC.1 live -- 2.54.0