From d5adcec567c5e03fb71c001caff5774897323550 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:20:32 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + README.md | 4 +- pom.xml | 8 +--- .../email/EmailCollaborationNotifier.java | 37 +++++++++-------- .../collaboration/notify/Notifications.java | 2 +- .../email/EmailCollaborationNotifierTest.java | 40 +++++++++++++------ 6 files changed, 53 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac8d8d..7c5178c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8550dce..42aff20 100644 --- a/README.md +++ b/README.md @@ -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 (`true`). 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 (`true`). 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:::`, 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`. diff --git a/pom.xml b/pom.xml index 80ef42e..db188c2 100644 --- a/pom.xml +++ b/pom.xml @@ -23,17 +23,13 @@ 21 0.8.15 - 0.1.0-RC.1 - - - com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email - com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email + 0.1.0-RC.2 com.kingsrook qbit-build-parent - 2.0.0 + 2.1.1 com.kingsrook.qbits diff --git a/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java b/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java index aa57c70..1242a55 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java @@ -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)); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java index e9a2fbf..296e9ab 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java @@ -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) diff --git a/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java index e193b69..114f421 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java @@ -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 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"); } -- 2.54.0