Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 ceeb134c90 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:42 -05:00
6 changed files with 53 additions and 38 deletions

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
### Changed
- Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and status mapping.
- Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC using core's `DataClassification`, owner-recipient hook, opt-in and `emailExternalApproved` gates. Scheduled-task wiring remains a separate integration step.
## Unreleased - Provider API extraction (#30)
+1 -1
View File
@@ -501,7 +501,7 @@ Apache 2.0.
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`.
This integration uses qbit-email **0.1.0-RC.1**. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
Gates, in order (each returns a status and queues nothing):
1. the task opted in (`NOT_REQUESTED`);
+2 -2
View File
@@ -29,7 +29,7 @@
<archunit.version>1.5.1</archunit.version>
<jackson.version>2.21.7</jackson.version>
<!-- optional email delivery; qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
<test.excludedGroups>live</test.excludedGroups>
</properties>
@@ -37,7 +37,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -55,7 +55,7 @@ public final class AssistantEmail
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
}
catch(ClassNotFoundException | LinkageError e)
{
@@ -24,13 +24,11 @@ package com.kingsrook.qbits.assistant.email;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
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.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -66,7 +64,7 @@ final class AssistantEmailSender
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
return (EmailApi.isInstalled(qInstance));
}
@@ -79,7 +77,7 @@ final class AssistantEmailSender
{
boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL);
EmailRequest request = new EmailRequest().withTo(recipient)
EmailApiRequest request = new EmailApiRequest().withTo(recipient)
.withIdempotencyKey("assistant:scheduled:" + notice.runId())
.withLinkedRecord(notice.runTable(), notice.runId())
.withSender(SENDER_QBIT, SENDER_PROCESS);
@@ -100,7 +98,7 @@ final class AssistantEmailSender
}
request.withVariables(variables);
return (map(EmailService.send(request)));
return (map(EmailApi.send(request)));
}
@@ -123,7 +121,7 @@ final class AssistantEmailSender
/*******************************************************************************
** Outbox result to the qbit's own vocabulary.
*******************************************************************************/
private static ScheduledEmailStatus map(EmailSendResult result)
private static ScheduledEmailStatus map(EmailApiResult result)
{
return (switch(result.status())
{
@@ -141,10 +139,10 @@ final class AssistantEmailSender
*******************************************************************************/
static void ensureTemplate() throws QException
{
EmailTemplates.ensure(new EmailTemplateDefinition()
EmailApi.ensureTemplate(new EmailApiTemplate()
.withName(TEMPLATE_NAME)
.withLabel("Assistant scheduled task result")
.withCategory(EmailCategory.NOTIFICATION)
.withCategory(EmailApiCategory.NOTIFICATION)
.withKind("MARKDOWN")
.withOwnerQbit(SENDER_QBIT)
.withSubject("Assistant task result: {{taskName}}")
@@ -26,14 +26,15 @@ import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
@@ -101,8 +102,8 @@ class AssistantEmailTest
}
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")
QContext.init(qInstance, new QSession().withPermissions(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess")));
new InsertAction().execute(new InsertInput("email_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))));
}
@@ -125,9 +126,20 @@ class AssistantEmailTest
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> messages() throws Exception
private List<EmailApiMessage> messages() throws Exception
{
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
return (EmailApi.findMessagesByIdempotencyKeyPrefix("assistant:scheduled:", 100));
}
/*******************************************************************************
** Inspect the test outbox through core as the authorized email administrator.
** The public email API deliberately excludes bodies and recipient addresses.
*******************************************************************************/
private QRecord storedMessage() throws Exception
{
return (new QueryAction().execute(new QueryInput("email_message")).getRecords().getFirst());
}
@@ -142,18 +154,20 @@ class AssistantEmailTest
assertThat(AssistantEmail.isAvailable(instance)).isTrue();
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
List<QRecord> messages = messages();
List<EmailApiMessage> messages = messages();
assertThat(messages).hasSize(1);
QRecord message = messages.get(0);
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
EmailApiMessage metadata = messages.getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:7");
assertThat(metadata.linkedTableName()).isEqualTo(RUN_TABLE);
assertThat(metadata.linkedRecordId()).isEqualTo("7");
assertThat(metadata.templateName()).isEqualTo("assistant_scheduled_result");
QRecord message = storedMessage();
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();
QRecord template = new QueryAction().execute(new QueryInput("email_template")).getRecords().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"));
@@ -173,13 +187,15 @@ class AssistantEmailTest
{
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");
EmailApiMessage metadata = messages().getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.SECURITY);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:8");
assertThat(metadata.linkedRecordId()).isEqualTo("8");
assertThat(metadata.templateName()).isEqualTo("email_secure_message");
QRecord message = storedMessage();
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");
}
@@ -201,7 +217,7 @@ class AssistantEmailTest
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");
assertThat(storedMessage().getValueString("toAddresses")).isEqualTo("user17@example.com");
}
@@ -230,7 +246,7 @@ class AssistantEmailTest
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));
assertThat(storedMessage().getValueString("htmlBody")).doesNotContain("y".repeat(281));
}