Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 d5adcec567 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:32 -05:00
james.maes 4eb5b70ade Merge pull request #17 from QRun-IO/feature/object-storage-rc4
feat(attachments): bucket upload cap via object storage api (RC.4)
2026-10-14 09:58:45 -05:00
james.maesandClaude Opus 5.5 da932fb309 feat(attachments): honor the bucket upload cap via object storage's api (qbit-object-storage 0.1.0-RC.4)
AttachmentStorage.maxBytes now reads StorageBucket.maxUploadBytes() from
the api package (null = no bucket limit); uploads stop at the smaller of
the bucket cap and the per-table cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:55:21 -05:00
james.maes 66189ec110 Merge pull request #15 from QRun-IO/feature/email-qbit
feat(email): email mentions, shares and task notices through qbit-email
2026-10-14 08:27:25 -05:00
7 changed files with 58 additions and 46 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
### Fixed
+2 -2
View File
@@ -280,9 +280,9 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
## Email through qbit-email
qbit-email 0.1.0-RC.1 is an optional dependency (`<optional>true</optional>`). When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier` (package `email`, the only class that references qbit-email). Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
qbit-email 0.1.0-RC.2 is an optional dependency (`<optional>true</optional>`). The integration uses its published `com.kingsrook.qbits.email.api` package for installation detection, template registration and sending. When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier`. Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
The parent 2.1.0 guardrail allowlists explicitly include the qbit-email artifact and API package, preserving the parent's existing allowances. The package allowance includes the root because `EmailRuntime.config` lives there; keep all direct email references inside the adapter. Strict UI API route checks are enabled.
The guardrail allowlist properties retain the object-storage API and `*-api` artifact allowances and add only the qbit-email artifact and `com.kingsrook.qbits.email.api` package. The current parent 2.0.0 does not execute guardrails; these properties are ready for a parent that does. Tests retain `EmailQBitProducer`/`EmailQBitConfig` only to assemble the host instance; G1 checks production classes and sources.
- Templates (NOTIFICATION, Markdown, owner `qbit-collaboration`), registered on first send and never overwritten: `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder` (task assignment, reminder and overdue). Follow digests are not emailed.
- One message per recipient, idempotency key `collaboration:<mention|share|task>:<event id>:<recipient user id>`, linked to the parent record, sender `qbit-collaboration`. A repeat is a duplicate. The event id is the mention, share, task or assignment row id (task reminders add the due field); `CollaborationNotification` carries it as `sourceId`, with the acting user as `actorUserId`.
+3 -7
View File
@@ -23,17 +23,13 @@
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<!-- Optional email integration: only EmailCollaborationNotifier links to its API. -->
<!-- EmailRuntime is in the root package; retain the parent's existing allowances. -->
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
</properties>
<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>
@@ -80,7 +76,7 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<version>0.1.0-RC.4</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -45,14 +45,13 @@ public class AttachmentStorage
}
/*******************************************************************************
** The bucket's own upload cap, when object storage exposes it through its api
** package. It does not yet (requested from qbit-object-storage), so this
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
** and object storage still enforces the bucket's cap when the object is put.
** The bucket's own upload cap, from object storage's api package; Long.MAX_VALUE
** when the bucket has no limit. Collaboration also enforces its per-table cap.
*******************************************************************************/
public long maxBytes(String name)
{
return (Long.MAX_VALUE);
Long max = bucket(name).maxUploadBytes();
return (max == null ? Long.MAX_VALUE : max);
}
/*******************************************************************************
@@ -30,13 +30,12 @@ import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
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.EmailApiStatus;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -78,7 +77,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
*******************************************************************************/
public static boolean isAvailable(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
return (EmailApi.isInstalled(qInstance));
}
@@ -91,7 +90,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
@Override
public void notify(CollaborationNotification n)
{
EmailTemplateDefinition template = templateFor(n.category());
EmailApiTemplate template = templateFor(n.category());
if(template == null)
{
return;
@@ -104,9 +103,9 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
return;
}
EmailTemplates.ensure(template);
EmailApi.ensureTemplate(template);
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
EmailRequest request = new EmailRequest().withTemplateName(template.getName()).withCategory(EmailCategory.NOTIFICATION).withTo(address)
EmailApiRequest request = new EmailApiRequest().withTemplateName(template.getName()).withCategory(EmailApiCategory.NOTIFICATION).withTo(address)
.withIdempotencyKey("collaboration:" + kind(n.category()) + ":" + eventId + ":" + n.recipientUserId())
.withSender(OWNER_QBIT, "notify-" + kind(n.category()))
.withVariable("actorName", actorName(n.actorUserId())).withVariable("recordLabel", recordLabel(n));
@@ -118,8 +117,8 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
{
request.withVariable("link", n.link());
}
EmailSendResult result = EmailService.send(request);
if(result.status() != EmailSendResult.Status.QUEUED)
EmailApiResult result = EmailApi.send(request);
if(result.status() != EmailApiStatus.QUEUED)
{
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
logPair("messageId", result.messageId()));
@@ -193,7 +192,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
/*******************************************************************************
** The shipped template for a category, or null when none exists.
*******************************************************************************/
static EmailTemplateDefinition templateFor(CollaborationNotification.Category category)
static EmailApiTemplate templateFor(CollaborationNotification.Category category)
{
return (switch(category)
{
@@ -212,9 +211,9 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
** the template needs no PHI approval because labels are passed only when the
** record label fields are fully accessible.
*******************************************************************************/
private static EmailTemplateDefinition definition(String name, String label, String subject, String body)
private static EmailApiTemplate definition(String name, String label, String subject, String body)
{
return (new EmailTemplateDefinition().withName(name).withLabel(label).withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN")
return (new EmailApiTemplate().withName(name).withLabel(label).withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN")
.withOwnerQbit(OWNER_QBIT).withSubject(subject).withBody(body)
.withVariable("actorName", "Person who acted", "STRING", true, "CONFIDENTIAL")
.withVariable("recordLabel", "Record label", "STRING", true, "CONFIDENTIAL")
@@ -227,7 +226,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
/*******************************************************************************
** Someone mentioned the recipient.
*******************************************************************************/
static EmailTemplateDefinition mentionTemplate()
static EmailApiTemplate mentionTemplate()
{
return (definition(MENTION_TEMPLATE, "Collaboration mention", "{{actorName}} mentioned you on {{recordLabel}}",
"**{{actorName}}** mentioned you on {{recordLabel}}.\n\n" + OPEN_LINK));
@@ -238,7 +237,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
/*******************************************************************************
** Someone shared a record with the recipient.
*******************************************************************************/
static EmailTemplateDefinition shareTemplate()
static EmailApiTemplate shareTemplate()
{
return (definition(SHARE_TEMPLATE, "Collaboration share", "{{actorName}} shared {{recordLabel}} with you",
"**{{actorName}}** shared {{recordLabel}} with you.\n\n" + OPEN_LINK));
@@ -249,7 +248,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
/*******************************************************************************
** A task assigned to the recipient was assigned, is due, or is overdue.
*******************************************************************************/
static EmailTemplateDefinition taskReminderTemplate()
static EmailApiTemplate taskReminderTemplate()
{
return (definition(TASK_REMINDER_TEMPLATE, "Collaboration task reminder", "A task on {{recordLabel}} needs your attention",
"A task assigned to you on {{recordLabel}} needs your attention.\n\n" + OPEN_LINK));
@@ -149,7 +149,7 @@ public final class Notifications
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance()));
}
catch(ClassNotFoundException | LinkageError e)
@@ -32,16 +32,20 @@ import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
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.collaboration.UserSessionProviderInterface;
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.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -96,25 +100,30 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
/*******************************************************************************
** Produce the email qbit in the test instance.
** Host fixture wiring only: G1 scans production classes and sources. Create
** the provider through standard QQQ actions with the documented permissions.
*******************************************************************************/
private void withEmail() throws Exception
{
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.org")
.withSecretResolver(name -> "k")).produce(instance);
QContext.getQSession().withPermissions(EmailPermissions.ALL);
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));
QContext.getQSession().withPermissions("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess");
QRecord 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);
assertThat(new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(provider))).getRecords())
.singleElement().satisfies(row -> assertThat(row.getErrors()).isNullOrEmpty());
}
/*******************************************************************************
** All outbox messages.
** Inspect rendered test content through the standard, permission-checked
** table action. The public API deliberately exposes only message metadata.
*******************************************************************************/
private List<QRecord> messages() throws Exception
{
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
return (new QueryAction().execute(new QueryInput("email_message").withFilter(new QQueryFilter())
.withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false)).getRecords());
}
@@ -154,7 +163,14 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
});
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name")).contains("collaboration_mention");
assertThat(EmailApi.getMessageByIdempotencyKey("collaboration:mention:7:bob")).hasValueSatisfying(message ->
{
assertThat(message.status()).isEqualTo("QUEUED");
assertThat(message.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(message.templateName()).isEqualTo("collaboration_mention");
assertThat(message.linkedTableName()).isEqualTo("document");
assertThat(message.linkedRecordId()).isEqualTo("1");
});
}
@@ -174,7 +190,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
assertThat(messages()).extracting(m -> m.getValueString("idempotencyKey"))
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob");
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name"))
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("collaboration:", 10)).extracting(EmailApiMessage::templateName)
.contains("collaboration_share", "collaboration_task_reminder");
}