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");
}