From c9954efef40a524d5fd93cdc182a5685a6e12382 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:12:39 -0500 Subject: [PATCH 1/2] feat(email): email mentions, shares and task notices through qbit-email Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an optional dependency (0.1.0-SNAPSHOT until its first RC) used through one adapter class behind a classpath and instance guard; without it the existing behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 13 +- pom.xml | 15 + .../CollaborationQBitConfig.java | 27 +- .../assignment/AssignmentService.java | 2 +- .../comments/CommentsService.java | 3 +- .../email/EmailCollaborationNotifier.java | 257 ++++++++++++++++++ .../notify/CollaborationNotification.java | 27 +- .../collaboration/notify/Notifications.java | 39 ++- .../security/OtherUserAccess.java | 59 ++++ .../collaboration/sharing/SharingService.java | 4 +- .../collaboration/tasks/TaskReminders.java | 2 +- .../collaboration/tasks/TasksService.java | 3 +- .../email/EmailCollaborationNotifierTest.java | 242 +++++++++++++++++ 14 files changed, 684 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c73a6ba..2af171c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ## [Unreleased] +### Changed + +- Email through qbit-email: 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`. + - Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests. ### Added diff --git a/README.md b/README.md index 0c5d597..74c3b72 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ UI API endpoints remain deferred to [QRun-IO/qqq#1128](https://github.com/QRun-I | `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. | | `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. | | `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. | -| `notifier` | `QCodeReference(NoOpNotifier.class)` | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. | +| `notifier` | None | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. When unset: `EmailCollaborationNotifier` if qbit-email is on the classpath and produced in the instance, otherwise `NoOpNotifier`. | +| `recipientEmailResolver` | People directory | `Function` from user id to email address for the email notifier. Unset reads the `email` of the matching `collaboration_person` row; no address means no email. | | `userSessionSupplier` | None | `QCodeReference` implementing `UserSessionSupplier`; missing user sessions mean unknown access. | | `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. | | `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. | @@ -205,6 +206,16 @@ UI API endpoints remain deferred to [QRun-IO/qqq#1128](https://github.com/QRun-I | `parentAppName` | None | Optional existing parent, such as `admin`. | | `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. | +## Email through qbit-email + +qbit-email 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. + +- 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`. +- No comment bodies, task titles, notes or share messages are emailed, since they may hold PHI. The email says who did what on which record, with a link. The record label is read as the recipient (needs `userSessionSupplier`) and used only when `FieldAccessHelper.isRecordLabelFullAccess` allows it (reflective guard); otherwise the text says "a record in ". Variables are classified CONFIDENTIAL so the templates need no PHI approval. +- The link is sent only when absolute: set `frontendBaseUrl`. +- Recipient addresses come from `withRecipientEmailResolver(Function)`, else the people directory email. Refused or suppressed sends are logged by user id and status only; delivery problems never fail the collaboration action. + ## Adopting in another app 1. Register the host backend and optional Admin app, then invoke the producer above. diff --git a/pom.xml b/pom.xml index 7cd418d..1680c8c 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,8 @@ 21 21 0.8.15 + + 0.1.0-SNAPSHOT @@ -76,6 +78,19 @@ com.kingsrook.qqq qqq-backend-core + + + com.kingsrook.qbits + qbit-email + ${qbit-email.version} + true + + + com.kingsrook.qqq + * + + + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index 6a62ab7..853d505 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -23,7 +23,7 @@ package com.kingsrook.qbits.collaboration; import java.util.List; -import com.kingsrook.qbits.collaboration.notify.NoOpNotifier; +import java.util.function.Function; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; @@ -38,7 +38,8 @@ public class CollaborationQBitConfig implements QBitConfig public static final String TABLE_NAME_PREFIX = "collaboration_"; public static final String PERMISSION_BASE_NAME = "collaboration"; - private QCodeReference notifier = new QCodeReference(NoOpNotifier.class); + private QCodeReference notifier = null; + private Function recipientEmailResolver = null; private QCodeReference userSessionSupplier = null; private String defaultTagMode = "FREE"; private List tagActionTables = List.of(); @@ -256,7 +257,6 @@ public class CollaborationQBitConfig implements QBitConfig assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors); } assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors); - assertCondition(notifier != null, "notifier is required", errors); for(List tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables)) { for(String table : tables) @@ -379,13 +379,32 @@ public class CollaborationQBitConfig implements QBitConfig } /******************************************************************************* - ** Get notifier. + ** Get notifier. Null (the default) means: email through qbit-email when it is + ** produced in the instance, otherwise no delivery transport. *******************************************************************************/ public QCodeReference getNotifier() { return (notifier); } + /******************************************************************************* + ** Get the user id to email address lookup used by the email notifier. + *******************************************************************************/ + public Function getRecipientEmailResolver() + { + return (recipientEmailResolver); + } + + /******************************************************************************* + ** Configure how the email notifier finds a recipient address from a user id. + ** Null (the default) uses the email stored in the collaboration people directory. + *******************************************************************************/ + public CollaborationQBitConfig withRecipientEmailResolver(Function recipientEmailResolver) + { + this.recipientEmailResolver = recipientEmailResolver; + return (this); + } + /******************************************************************************* ** Configure notifier. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java b/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java index 110ed0e..5cd6ee1 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java @@ -79,7 +79,7 @@ public class AssignmentService if(!assigneeUserId.equals(PersonalRecords.currentUserId())) { Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null, - CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id))); + CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id"), PersonalRecords.currentUserId())); } } return (row); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java index 4fadf33..427ae70 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java @@ -317,7 +317,8 @@ public class CommentsService private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException { Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category, - row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))), + row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))) + .withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()), mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION, new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now()))); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java b/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java new file mode 100644 index 0000000..5396b72 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifier.java @@ -0,0 +1,257 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. 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.collaboration.email; + + +import java.util.List; +import java.util.function.Function; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +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.OtherUserAccess; +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.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +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.QInstance; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Delivers mention, share and task notifications through qbit-email, one + ** NOTIFICATION message per recipient. This is the only class that references + ** qbit-email; Notifications reaches it only after checking that the email qbit + ** is loadable and produced in the instance. + ** + ** Emails never carry comment bodies, task titles or notes (they may hold PHI): + ** they say who did what on which record, with a link. The record label is + ** included only when the recipient may read the record and every label field is + ** fully accessible; otherwise a generic label is used. + *******************************************************************************/ +public final class EmailCollaborationNotifier implements CollaborationNotifier +{ + private static final QLogger LOG = QLogger.getLogger(EmailCollaborationNotifier.class); + + public static final String OWNER_QBIT = "qbit-collaboration"; + public static final String MENTION_TEMPLATE = "collaboration_mention"; + public static final String SHARE_TEMPLATE = "collaboration_share"; + public static final String TASK_REMINDER_TEMPLATE = "collaboration_task_reminder"; + + private static final String SAMPLE = "{\"actorName\":\"Alex Example\",\"recordLabel\":\"Order 1042\",\"link\":\"https://example.invalid/app/orders/1042\"}"; + private static final String OPEN_LINK = "{{#if link}}[Open the record]({{link}}){.button}{{/if}}"; + + + + /******************************************************************************* + ** True when the email qbit is produced in the given instance. + *******************************************************************************/ + public static boolean isAvailable(QInstance qInstance) + { + return (EmailRuntime.config(qInstance) != null); + } + + + + /******************************************************************************* + ** Enqueue the email for a mention, share or task notification; follow digests + ** have no email template. Failures are logged by user id and never thrown, so + ** a mail problem cannot fail the collaboration action. + *******************************************************************************/ + @Override + public void notify(CollaborationNotification n) + { + EmailTemplateDefinition template = templateFor(n.category()); + if(template == null) + { + return; + } + try + { + String address = address(n.recipientUserId()); + if(!StringUtils.hasContent(address)) + { + LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId())); + return; + } + EmailTemplates.ensure(template); + String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId(); + EmailRequest request = new EmailRequest().withTemplateName(template.getName()).withCategory(EmailCategory.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)); + if(n.sourceTableName() != null && n.sourceRecordId() != null) + { + request.withLinkedRecord(n.sourceTableName(), n.sourceRecordId()); + } + if(n.link() != null && n.link().startsWith("http")) + { + request.withVariable("link", n.link()); + } + EmailSendResult result = EmailService.send(request); + if(result.status() != EmailSendResult.Status.QUEUED) + { + LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()), + logPair("messageId", result.messageId())); + } + } + catch(QException | RuntimeException e) + { + LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category())); + } + } + + + + /******************************************************************************* + ** The recipient address from the configured resolver, else from the + ** collaboration people directory. + *******************************************************************************/ + private String address(String userId) throws QException + { + Function resolver = CollaborationRecords.config().getRecipientEmailResolver(); + if(resolver != null) + { + return (resolver.apply(userId)); + } + List rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, userId)); + return (rows.isEmpty() ? null : rows.getFirst().getValueString("email")); + } + + + + /******************************************************************************* + ** The person's display name, or a generic word when the actor is unknown. + *******************************************************************************/ + private String actorName(String actorUserId) throws QException + { + return (actorUserId == null ? "Someone" : new PeopleService().displayName(actorUserId)); + } + + + + /******************************************************************************* + ** The record label when the recipient may see it, else a generic phrase. + *******************************************************************************/ + private String recordLabel(CollaborationNotification n) throws QException + { + if(n.sourceTableName() == null) + { + return ("a record"); + } + return (new OtherUserAccess().recordLabel(n.recipientUserId(), n.sourceTableName(), n.sourceRecordId()) + .orElse("a record in " + QContext.getQInstance().getTable(n.sourceTableName()).getLabel())); + } + + + + /******************************************************************************* + ** Idempotency kind for a category. + *******************************************************************************/ + private static String kind(CollaborationNotification.Category category) + { + return (switch(category) + { + case MENTION -> "mention"; + case SHARE -> "share"; + default -> "task"; + }); + } + + + + /******************************************************************************* + ** The shipped template for a category, or null when none exists. + *******************************************************************************/ + static EmailTemplateDefinition templateFor(CollaborationNotification.Category category) + { + return (switch(category) + { + case MENTION -> mentionTemplate(); + case SHARE -> shareTemplate(); + case TASK -> taskReminderTemplate(); + default -> null; + }); + } + + + + /******************************************************************************* + ** Shared definition: variable schema and ownership. Existing templates, and + ** people's edits, are never touched. Labels are CONFIDENTIAL, not PII or PHI: + ** 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) + { + return (new EmailTemplateDefinition().withName(name).withLabel(label).withCategory(EmailCategory.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") + .withVariable("link", "Link to the record", "URL", false, "PUBLIC") + .withSampleVariables(SAMPLE)); + } + + + + /******************************************************************************* + ** Someone mentioned the recipient. + *******************************************************************************/ + static EmailTemplateDefinition mentionTemplate() + { + return (definition(MENTION_TEMPLATE, "Collaboration mention", "{{actorName}} mentioned you on {{recordLabel}}", + "**{{actorName}}** mentioned you on {{recordLabel}}.\n\n" + OPEN_LINK)); + } + + + + /******************************************************************************* + ** Someone shared a record with the recipient. + *******************************************************************************/ + static EmailTemplateDefinition shareTemplate() + { + return (definition(SHARE_TEMPLATE, "Collaboration share", "{{actorName}} shared {{recordLabel}} with you", + "**{{actorName}}** shared {{recordLabel}} with you.\n\n" + OPEN_LINK)); + } + + + + /******************************************************************************* + ** A task assigned to the recipient was assigned, is due, or is overdue. + *******************************************************************************/ + static EmailTemplateDefinition 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/CollaborationNotification.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java index fc6b8d2..dc76b5d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java @@ -24,10 +24,35 @@ package com.kingsrook.qbits.collaboration.notify; /******************************************************************************* ** Transport-neutral notification. Body is markdown; link is a record href. + ** sourceId identifies the event (mention, share, task or assignment row) so a + ** transport can de-duplicate; actorUserId is the person who caused it. Both are + ** optional. *******************************************************************************/ public record CollaborationNotification(String recipientUserId, String title, String body, Category category, - String sourceTableName, String sourceRecordId, String link) + String sourceTableName, String sourceRecordId, String link, String sourceId, String actorUserId) { + /******************************************************************************* + ** Notification without an event id or actor. + *******************************************************************************/ + public CollaborationNotification(String recipientUserId, String title, String body, Category category, + String sourceTableName, String sourceRecordId, String link) + { + this(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link, null, null); + } + + + + /******************************************************************************* + ** Copy carrying the event id and actor. + *******************************************************************************/ + public CollaborationNotification withEvent(Object eventId, String actor) + { + return (new CollaborationNotification(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link, + eventId == null ? null : String.valueOf(eventId), actor)); + } + + + /******************************************************************************* ** Categories supported by the collaboration notification boundary. *******************************************************************************/ 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 d47caa2..b88fe06 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java @@ -22,11 +22,13 @@ package com.kingsrook.qbits.collaboration.notify; import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.email.EmailCollaborationNotifier; import com.kingsrook.qbits.collaboration.security.OtherUserAccess; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod; /******************************************************************************* @@ -99,7 +101,7 @@ public final class Notifications { if(!checkParent || new OtherUserAccess().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false)) { - CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier()); + CollaborationNotifier notifier = notifier(); notifier.notify(notification); if(receipt != null && !(notifier instanceof NoOpNotifier)) { @@ -108,6 +110,41 @@ public final class Notifications } } + /******************************************************************************* + ** The configured notifier; with none configured, email through qbit-email when + ** it is on the classpath and produced in this instance, else no transport. + *******************************************************************************/ + private static CollaborationNotifier notifier() throws QException + { + QCodeReference configured = CollaborationRecords.config().getNotifier(); + if(configured != null) + { + return (QCodeLoader.getAdHoc(CollaborationNotifier.class, configured)); + } + if(emailAvailable()) + { + return (new EmailCollaborationNotifier()); + } + return (new NoOpNotifier()); + } + + /******************************************************************************* + ** qbit-email is optional: the adapter is touched only after the class check + ** passes and the email qbit is produced in the current instance. + *******************************************************************************/ + private static boolean emailAvailable() + { + try + { + Class.forName("com.kingsrook.qbits.email.outbox.EmailService"); + return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance())); + } + catch(ClassNotFoundException | LinkageError e) + { + return (false); + } + } + /******************************************************************************* ** Relative canonical frontend link with separately encoded path segments. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java index 808cebd..732cca7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java @@ -27,10 +27,14 @@ import java.util.concurrent.atomic.AtomicReference; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; import com.kingsrook.qbits.collaboration.CollaborationRecords; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.session.QSession; /******************************************************************************* @@ -73,4 +77,59 @@ public class OtherUserAccess }); return (Optional.of(result.get())); } + + /******************************************************************************* + ** The record label as the recipient may see it: empty unless a session for the + ** recipient exists, the record is readable, and every label field is fully + ** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ + ** versions, which means no label). + *******************************************************************************/ + public Optional recordLabel(String userId, String table, Serializable recordId) throws QException + { + CollaborationQBitConfig config = CollaborationRecords.config(); + if(config.getUserSessionSupplier() == null || table == null || recordId == null) + { + return (Optional.empty()); + } + Optional session = QCodeLoader.getAdHoc(UserSessionSupplier.class, config.getUserSessionSupplier()).sessionFor(userId); + if(session.isEmpty() || session.get().getUser() == null || !userId.equals(session.get().getUser().getIdReference())) + { + return (Optional.empty()); + } + AtomicReference result = new AtomicReference<>(); + QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session.get()), () -> + { + try + { + ParentRecordAccess.requireVisibleParent(table, recordId); + if(labelFullyAccessible(table)) + { + GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true); + QRecord record = new GetAction().executeForRecord(input); + result.set(record == null ? null : record.getRecordLabel()); + } + } + catch(QNotFoundException expected) + { + // Not readable by the recipient: no label. + } + }); + return (Optional.ofNullable(result.get()).filter(label -> !label.isBlank())); + } + + /******************************************************************************* + ** Reflective guard: the helper only exists in QQQ versions with secured data. + *******************************************************************************/ + private static boolean labelFullyAccessible(String table) + { + try + { + Class helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper"); + return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table)))); + } + catch(ReflectiveOperationException | LinkageError e) + { + return (false); + } + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java index 9af07fa..ed6249d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java @@ -98,7 +98,7 @@ public class SharingService if(Boolean.TRUE.equals(recipient.canRead())) { Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message, - CollaborationNotification.Category.SHARE, table, id, link.url())); + CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from)); new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED"); } else @@ -106,7 +106,7 @@ public class SharingService Notifications.sendAccessNotice(new CollaborationNotification(recipient.userId(), name + " wants to share a record in " + QContext.getQInstance().getTable(table).getLabel() + " with you", "Request access to open this record.", CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/create?tableName=" - + TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id"))); + + TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")).withEvent(row.getValue("id"), from)); } ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id")); result.add(row); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java index a9afc0e..c7ac816 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java @@ -83,7 +83,7 @@ public class TaskReminders String id = row.getValueString("recordId"); Notifications.send(new CollaborationNotification(row.getValueString("assigneeUserId"), (dueField.equals("remindAt") ? "Task reminder: " : "Task overdue: ") + row.getValueString("title"), row.getValueString("notes"), - CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)), + CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id") + ":" + dueField, null), () -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now))); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java index 44718f0..3e31b17 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java @@ -266,7 +266,8 @@ public class TasksService if(!user.equals(PersonalRecords.currentUserId())) { Notifications.send(new CollaborationNotification(user, "Task assigned: " + row.getValueString("title"), row.getValueString("notes"), - CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id))); + CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)) + .withEvent(row.getValue("id"), PersonalRecords.currentUserId())); } } } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java new file mode 100644 index 0000000..4945eb1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/email/EmailCollaborationNotifierTest.java @@ -0,0 +1,242 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. 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.collaboration.email; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.PersonalServiceFixture; +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.qbits.collaboration.security.UserSessionSupplier; +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.qqq.backend.core.context.QContext; +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.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Collaboration notifications through a real qbit-email outbox (memory backend). + *******************************************************************************/ +class EmailCollaborationNotifierTest extends PersonalServiceFixture +{ + private static final Map sessions = new HashMap<>(); + + + + /******************************************************************************* + ** Sessions for the recipients, so access is re-checked as them. + *******************************************************************************/ + public static class TestSessions implements UserSessionSupplier + { + /******************************************************************************* + ** The recipient own session. + *******************************************************************************/ + @Override + public Optional sessionFor(String user) + { + return (Optional.ofNullable(sessions.get(user))); + } + } + + + + /******************************************************************************* + ** Recipients bob and eve can read the document and have addresses. + *******************************************************************************/ + @BeforeEach + void configure() throws Exception + { + sessions.clear(); + for(String user : List.of("alice", "bob", "eve")) + { + sessions.put(user, new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", "a")); + } + config.withUserSessionSupplier(new QCodeReference(TestSessions.class)); + CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "bob").withValue("displayName", "Bob").withValue("email", "bob@example.org")); + CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Eve").withValue("email", "eve@example.org")); + } + + + + /******************************************************************************* + ** Produce the email qbit in the test instance. + *******************************************************************************/ + 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)); + } + + + + /******************************************************************************* + ** All outbox messages. + *******************************************************************************/ + private List messages() throws Exception + { + return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter())); + } + + + + /******************************************************************************* + ** A notification of a category for a recipient. + *******************************************************************************/ + private CollaborationNotification notification(String recipient, CollaborationNotification.Category category, String eventId) + { + return (new CollaborationNotification(recipient, "You were mentioned in a comment", "secret comment body", category, "document", "1", + "https://app.example.org/app/document/1", eventId, "alice")); + } + + + + /******************************************************************************* + ** With the email qbit present and no notifier configured, a mention becomes one + ** NOTIFICATION message with the key, link and record, and no comment body; a + ** repeat is a duplicate. + *******************************************************************************/ + @Test + void mentionQueuesOneMessageAndRepeatIsDuplicate() throws Exception + { + withEmail(); + assertThat(config.getNotifier()).isNull(); + Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7")); + Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7")); + assertThat(messages()).singleElement().satisfies(message -> + { + assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(message.getValueString("toAddresses")).contains("bob@example.org").doesNotContain("eve@example.org"); + assertThat(message.getValueString("idempotencyKey")).isEqualTo("collaboration:mention:7:bob"); + assertThat(message.getValueString("linkedTableName")).isEqualTo("document"); + assertThat(message.getValueString("linkedRecordId")).isEqualTo("1"); + assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration"); + assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents"); + 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"); + } + + + + /******************************************************************************* + ** Each recipient gets their own message; the share and task kinds use their + ** own templates and key kinds; follow digests send no email. + *******************************************************************************/ + @Test + void oneMessagePerRecipientAndKinds() throws Exception + { + withEmail(); + Notifications.send(notification("bob", CollaborationNotification.Category.SHARE, "3")); + Notifications.send(notification("eve", CollaborationNotification.Category.SHARE, "3")); + Notifications.send(notification("bob", CollaborationNotification.Category.TASK, "9:remindAt")); + Notifications.send(notification("bob", CollaborationNotification.Category.FOLLOW, "1")); + 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")) + .contains("collaboration_share", "collaboration_task_reminder"); + } + + + + /******************************************************************************* + ** The configured resolver replaces the directory; no address means no email. + *******************************************************************************/ + @Test + void resolverAndMissingAddress() throws Exception + { + withEmail(); + config.withRecipientEmailResolver(user -> "eve".equals(user) ? "other@example.org" : null); + Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1")); + Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2")); + assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org")); + } + + + + /******************************************************************************* + ** Real shares and task assignments carry the event id and actor into the email. + *******************************************************************************/ + @Test + void sharesAndTasksThroughTheServices() throws Exception + { + withEmail(); + new SharingService().share("document", 1, List.of("bob"), "private message"); + new TasksService().create("document", 1, "Private task title", "private notes", "bob", null, null); + assertThat(messages()).hasSize(2).allSatisfy(m -> + { + assertThat(m.getValueString("subject") + m.getValueString("textBody") + m.getValueString("htmlBody")).doesNotContain("private", "Private"); + assertThat(m.getValueString("idempotencyKey")).matches("collaboration:(share|task):\\d+:bob"); + }); + assertThat(messages().getFirst().getValueString("subject")).startsWith("alice shared"); + } + + + + /******************************************************************************* + ** Without the email qbit in the instance nothing is sent and no receipt runs. + *******************************************************************************/ + @Test + void withoutEmailQbitNothingHappens() throws Exception + { + boolean[] receipt = {false}; + Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"), () -> receipt[0] = true); + assertThat(receipt[0]).isFalse(); + assertThat(EmailCollaborationNotifier.isAvailable(instance)).isFalse(); + } + + + + /******************************************************************************* + ** A configured notifier always wins over the email default. + *******************************************************************************/ + @Test + void configuredNotifierWins() throws Exception + { + withEmail(); + config.withNotifier(new QCodeReference(NoOpNotifier.class)); + Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7")); + assertThat(messages()).isEmpty(); + } +} -- 2.54.0 From f0b8fc9141b5425d8f1b76a07f7d4bdc85dc85f3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:58:55 -0500 Subject: [PATCH 2/2] build: qbit-email 0.1.0-RC.1 from Maven Central Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas gain the RC's email_provider_sender_address table and the message from_address_id column where they copy the qbit's DDL. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 1680c8c..03f93fc 100644 --- a/pom.xml +++ b/pom.xml @@ -22,8 +22,8 @@ 21 21 0.8.15 - - 0.1.0-SNAPSHOT + + 0.1.0-RC.1 -- 2.54.0