feat(email): scheduled-result email adapter for qbit-email #39
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Email
|
||||
|
||||
### Changed
|
||||
|
||||
- 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)
|
||||
|
||||
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
|
||||
|
||||
@@ -206,6 +206,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.
|
||||
|
||||
@@ -464,6 +466,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`.
|
||||
|
||||
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.
|
||||
|
||||
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:<run id>`, 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 (core's `DataClassification`): 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
|
||||
|
||||
Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set):
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<anthropic.version>2.71.0</anthropic.version>
|
||||
<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>
|
||||
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
|
||||
<test.excludedGroups>live</test.excludedGroups>
|
||||
</properties>
|
||||
|
||||
@@ -49,6 +49,15 @@
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional email delivery of scheduled-task results. Every reference is in -->
|
||||
<!-- the email package; without the jar nothing changes. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>${qbit-email.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -145,11 +145,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<QTableMetaData> tableMetaDataCustomizer;
|
||||
private transient Map<String, AssistantModelProvider> modelProviders = new LinkedHashMap<>();
|
||||
private transient Map<String, AssistantProviderType> providerTypes = new LinkedHashMap<>();
|
||||
private transient Function<String, String> secretResolver = System::getenv;
|
||||
private transient Function<String, String> emailRecipientResolver;
|
||||
private transient AssistantRuntime runtime;
|
||||
|
||||
|
||||
@@ -1502,6 +1504,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<String, String> getEmailRecipientResolver()
|
||||
{
|
||||
return (emailRecipientResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailRecipientResolver
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withEmailRecipientResolver(Function<String, String> emailRecipientResolver)
|
||||
{
|
||||
this.emailRecipientResolver = emailRecipientResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedApiKeyEnvNames (extra API key environment variable names a provider row may reference)
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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.
|
||||
**
|
||||
** Hosts can call sendScheduledResult after a run; the scheduled-task runner
|
||||
** does not yet wire email delivery. 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());
|
||||
}
|
||||
}
|
||||
+158
@@ -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.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.actions.ai.DataClassification;
|
||||
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<String, Serializable> 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\"}"));
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
+46
@@ -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.qqq.backend.core.actions.ai.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)
|
||||
{
|
||||
}
|
||||
+249
@@ -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.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.ai.DataClassification;
|
||||
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<QRecord> 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<QRecord> 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user