diff --git a/README.md b/README.md index aecb266..0a1cfe7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT - **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it. - **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts. - **Producers** send with `AlertsRuntime.get(qInstance).sink().notify(new NotificationSignal(...))` (core: `QAlerts.notify`). Alerts a user subscribes to arrive in the bell as ALERT notifications. +- **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens. +- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link. +- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`). +- **Links** must be app paths with one leading slash, or https URLs; backslashes, whitespace and control characters are refused. - **People** see them in the Next UI bell and notifications page, and choose per-category settings in My Notification Settings (security notices can't be muted). Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions include their own recipient rows and settings. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 3b03152..26df034 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -188,7 +188,7 @@ Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.al is hidden. QQQ has no SSE today. - **Popover** (lazy chunk): newest first, a severity icon, the title, the source, a relative time, and a link. You can mark one read or unread, or dismiss it, and the footer has "Mark all read" and "View all". -- **Page** `/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown +- **Page** `/app/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown body, and the same actions. - **Banner:** sticky, undismissed, unexpired items show above the page content until dismissed. - **Budget:** total JS 1100 KB. The badge adds well under 1 KB to first load; the popover and the page are diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java index 0476256..9873dd0 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java @@ -85,7 +85,7 @@ public class AlertsApi static final int MAX_PAGE = 100; static final int POLL_SECONDS = 60; - public static final String NOTIFICATIONS_HREF = "/notifications"; + public static final String NOTIFICATIONS_HREF = "/app/notifications"; static final int PANEL_ROWS = 5; static final int RESOLVED_LOOKBACK = 30; diff --git a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java index fb2b20a..6452d2f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java +++ b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java @@ -42,6 +42,7 @@ import com.kingsrook.qbits.alerts.model.AlertsChannel; import com.kingsrook.qbits.alerts.model.AlertsDelivery; import com.kingsrook.qbits.alerts.model.AlertsEvent; import com.kingsrook.qbits.alerts.model.AlertsGroup; +import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus; @@ -50,6 +51,7 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status; import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsType; +import com.kingsrook.qbits.alerts.notifications.NotificationService; import com.kingsrook.qbits.alerts.spi.AlertSeverity; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; @@ -85,6 +87,7 @@ public final class AlertsHome public static final String KIND_TREND = "trend"; public static final String KIND_NOISY = "noisy"; public static final String KIND_DELIVERY = "delivery"; + public static final String KIND_NOTIFICATIONS = "notifications"; public static final String KIND_RESOLVED = "resolved"; static final int ROWS = 10; @@ -125,6 +128,7 @@ public final class AlertsHome case KIND_TREND -> trend(now); case KIND_NOISY -> noisy(now); case KIND_DELIVERY -> delivery(now); + case KIND_NOTIFICATIONS -> notifications(now); case KIND_RESOLVED -> resolved(now); default -> new RawHTML(null, HomeHtml.emptyState("Unknown Alerts widget.")); }; @@ -373,6 +377,28 @@ public final class AlertsHome + /******************************************************************************* + ** Notifications, link-only (the Notifications table is the owner): + ** scheduled, sent this week, drafts. + *******************************************************************************/ + static QWidgetData notifications(Instant now) throws QException + { + if(!canRead(AlertsNotification.TABLE_NAME)) + { + return (new RawHTML(null, HomeHtml.emptyState("Notifications are shown to notification authors."))); + } + QQueryFilter scheduled = AlertsDb.eq("status", NotificationService.STATUS_SCHEDULED); + QQueryFilter drafts = AlertsDb.eq("status", NotificationService.STATUS_DRAFT); + QQueryFilter sent = AlertsDb.eq("status", NotificationService.STATUS_SENT).withCriteria("category", QCriteriaOperator.NOT_EQUALS, "ALERT") + .withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)); + String line = HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, scheduled), HomeHtml.plural(AlertsDb.count(AlertsNotification.TABLE_NAME, scheduled), "scheduled notification")) + + " · " + HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, sent), HomeHtml.number(AlertsDb.count(AlertsNotification.TABLE_NAME, sent)) + " sent this week") + + " · " + HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, drafts), HomeHtml.plural(AlertsDb.count(AlertsNotification.TABLE_NAME, drafts), "draft")); + return (new RawHTML(null, "

" + line + "

")); + } + + + /******************************************************************************* ** Delivery health (alerts admins): unhealthy channels and failed or dead ** deliveries in 24 hours; one line when all is well. diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java index 2590623..8f0f5c3 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java @@ -46,6 +46,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd public static final String NOISY = "alertsHomeNoisy"; public static final String DELIVERY = "alertsHomeDeliveryHealth"; public static final String RESOLVED = "alertsHomeRecentlyResolved"; + public static final String NOTICES = "alertsHomeNotifications"; @@ -54,7 +55,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd *******************************************************************************/ public static List homeWidgetNames() { - return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, RESOLVED)); + return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED)); } @@ -76,6 +77,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd output.add(widget(TREND, "Opened and Resolved, 30 Days", WidgetType.LINE_CHART.getType(), AlertsHome.KIND_TREND, AlertsQBitConfig.PERMISSION_VIEW)); output.add(widget(NOISY, "Noisiest Alert Types, 7 Days", table, AlertsHome.KIND_NOISY, AlertsQBitConfig.PERMISSION_VIEW)); output.add(widget(DELIVERY, "Delivery Health", html, AlertsHome.KIND_DELIVERY, AlertsQBitConfig.PERMISSION_ADMIN)); + output.add(widget(NOTICES, "Notifications", html, AlertsHome.KIND_NOTIFICATIONS, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN)); output.add(widget(RESOLVED, "Recently Resolved", table, AlertsHome.KIND_RESOLVED, AlertsQBitConfig.PERMISSION_VIEW)); return (output); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java new file mode 100644 index 0000000..63d2b25 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * 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 + * + * http://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.alerts.notifications; + + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.spi.AlertLink; +import com.kingsrook.qbits.alerts.spi.NotificationAudience; +import com.kingsrook.qbits.alerts.spi.NotificationCategory; +import com.kingsrook.qbits.alerts.spi.NotificationSeverity; +import com.kingsrook.qbits.alerts.spi.NotificationSignal; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** Ready-made notifications for the automatic sources hosts wire up + ** (docs/NOTIFICATIONS.md 6.1): security notices, shares and assignments, + ** and release announcements. Each one is a NotificationSignal sent through + ** the alerts sink (queued; it never throws into the caller). With no Alerts + ** qbit installed they do nothing. + ** + ** Core has no sign-in or API key events yet, so a host calls the security + ** notices from its authentication customizer or API key screens. + *******************************************************************************/ +public final class AlertsNotices +{ + private static final QLogger LOG = QLogger.getLogger(AlertsNotices.class); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC); + + + + /******************************************************************************* + ** Helpers only. + *******************************************************************************/ + private AlertsNotices() + { + } + + + + /******************************************************************************* + ** A sign-in from a new device or place (where: e.g. "Firefox on Windows, + ** near Columbus, OH"). + *******************************************************************************/ + public static void newSignIn(String userId, String where) + { + send(new NotificationSignal("New sign-in to your account") + .withBody("A sign-in from " + where + ". If this wasn't you, change your password now.") + .withSeverity(NotificationSeverity.WARNING), userId, null); + } + + + + /******************************************************************************* + ** An API key was created for the user. + *******************************************************************************/ + public static void apiKeyCreated(String userId, String keyLabel) + { + send(new NotificationSignal("API key created: " + keyLabel) + .withBody("A new API key was created on your account. If you didn't create it, revoke it now.") + .withSeverity(NotificationSeverity.INFO), userId, null); + } + + + + /******************************************************************************* + ** An API key expires soon (one notification per key, refreshed on repeat). + *******************************************************************************/ + public static void apiKeyExpiring(String userId, String keyLabel, Instant expiresAt) + { + send(new NotificationSignal("API key expires " + DATE.format(expiresAt) + ": " + keyLabel) + .withBody("Create a new key and update the clients that use this one before it expires.") + .withSeverity(NotificationSeverity.WARNING), userId, "apikey-expiring:" + keyLabel); + } + + + + /******************************************************************************* + ** The user's permissions or roles changed (what: e.g. "You were added to + ** the Editors role"). + *******************************************************************************/ + public static void permissionChanged(String userId, String what) + { + send(new NotificationSignal("Your access changed") + .withBody(what + ". If you didn't expect this, contact an administrator.") + .withSeverity(NotificationSeverity.INFO), userId, null); + } + + + + /******************************************************************************* + ** Something was shared with or assigned to a user (a notebook, a record + ** lock, an approval waiting for them). + *******************************************************************************/ + public static void shared(String userId, String title, AlertLink link, String sourceName) + { + sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link) + .withAudience(NotificationAudience.user(userId))); + } + + + + /******************************************************************************* + ** A release announcement to every user ("Version 2.4 is out"). + *******************************************************************************/ + public static void release(String version, String notesMarkdown, AlertLink link) + { + sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS) + .withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link) + .withAudience(NotificationAudience.allUsers())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void send(NotificationSignal signal, String userId, String sourceKey) + { + if(userId == null) + { + return; + } + sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey) + .withAudience(NotificationAudience.user(userId))); + } + + + + /******************************************************************************* + ** Through the installed qbit's sink (nothing when it isn't installed). + *******************************************************************************/ + private static void sendSignal(NotificationSignal signal) + { + try + { + QInstance qInstance = QContext.getQInstance(); + AlertsRuntime runtime = AlertsRuntime.find(qInstance); + if(runtime != null && signal.getAudience() != null) + { + runtime.sink().notify(signal); + } + } + catch(Exception e) + { + LOG.warn("Could not send a notification", e); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java new file mode 100644 index 0000000..f874d10 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -0,0 +1,372 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * 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 + * + * http://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.alerts.notifications; + + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsNotification; +import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qbits.alerts.spi.NotificationCategory; +import com.kingsrook.qbits.alerts.spi.NotificationSeverity; +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole; +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.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Email for user notifications (docs/NOTIFICATIONS.md section 7), opt-in per + ** user and category in My Notification Settings: + ** - IMMEDIATE: one email per notification at or above the minimum severity; + ** - DAILY_DIGEST: one email at the user's digest hour (in their time zone, + ** from My Alert Settings) listing the day's unread notifications. + ** Inert without a messaging provider and a From address. ALERT + ** notifications are never emailed here (alert subscriptions already email + ** them). SECURITY emails carry the title and link only. Runs as the system + ** user from the delivery job. + *******************************************************************************/ +public class NotificationEmailer +{ + private static final QLogger LOG = QLogger.getLogger(NotificationEmailer.class); + + static final int SCAN = 500; + + private final AlertsRuntime runtime; + + + + /******************************************************************************* + ** What one run did. + *******************************************************************************/ + public record Result(int sent, int digests, int skipped, int failed) + { + } + + + + /******************************************************************************* + ** Constructor + *******************************************************************************/ + public NotificationEmailer(AlertsRuntime runtime) + { + this.runtime = runtime; + } + + + + /******************************************************************************* + ** Whether email can be sent at all. + *******************************************************************************/ + public boolean isConfigured() + { + AlertsQBitConfig config = runtime.getConfig(); + return (StringUtils.hasContent(config.getMessagingProviderName()) && StringUtils.hasContent(config.getFromAddress())); + } + + + + /******************************************************************************* + ** Send what is due: immediate emails, and digests for users at their hour. + *******************************************************************************/ + public Result run() throws QException + { + if(!isConfigured()) + { + return (new Result(0, 0, 0, 0)); + } + Instant now = runtime.now(); + return (runtime.asSystem(() -> + { + Map> preferences = new HashMap<>(); + for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter() + .withCriteria("emailMode", QCriteriaOperator.IN, List.of(EmailMode.IMMEDIATE.name(), EmailMode.DAILY_DIGEST.name())))) + { + preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference); + } + if(preferences.isEmpty()) + { + return (new Result(0, 0, 0, 0)); + } + + List recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter() + .withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet())) + .withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())) + .withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)) + .withLimit(SCAN)); + Map notifications = notifications(recipients, now); + Map settings = userSettings(preferences.keySet()); + + int sent = 0; + int skipped = 0; + int failed = 0; + Map> digests = new LinkedHashMap<>(); + for(QRecord recipient : recipients) + { + QRecord notification = notifications.get(recipient.getValueInteger("notificationId")); + String userId = recipient.getValueString("userId"); + QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category")); + if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category")) + || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))) + { + mark(recipient, EmailStatus.SKIPPED, null); + skipped++; + } + else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode"))) + { + boolean ok = send(address(settings.get(userId)), notification.getValueString("title"), List.of(notification)); + mark(recipient, ok ? EmailStatus.SENT : EmailStatus.FAILED, ok ? now : null); + if(ok) + { + sent++; + } + else + { + failed++; + } + } + else if(recipient.getValue("readAt") == null) + { + digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification}); + } + } + + int digestCount = 0; + for(Map.Entry> digest : digests.entrySet()) + { + QRecord setting = settings.get(digest.getKey()); + if(!isDigestHour(setting, now)) + { + continue; + } + List items = digest.getValue().stream().map(pair -> pair[1]).toList(); + boolean ok = send(address(setting), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items); + for(QRecord[] pair : digest.getValue()) + { + mark(pair[0], ok ? EmailStatus.DIGESTED : EmailStatus.FAILED, ok ? now : null); + } + digestCount += ok ? 1 : 0; + failed += ok ? 0 : 1; + } + return (new Result(sent, digestCount, skipped, failed)); + })); + } + + + + /******************************************************************************* + ** Whether it is the user's digest hour now (their time zone, default UTC). + *******************************************************************************/ + static boolean isDigestHour(QRecord setting, Instant now) + { + Integer hour = setting == null ? null : setting.getValueInteger("digestHour"); + ZoneId zone = ZoneOffset.UTC; + try + { + if(setting != null && StringUtils.hasContent(setting.getValueString("timeZone"))) + { + zone = ZoneId.of(setting.getValueString("timeZone")); + } + } + catch(DateTimeException e) + { + zone = ZoneOffset.UTC; + } + return (now.atZone(zone).getHour() == (hour == null ? 8 : hour)); + } + + + + /******************************************************************************* + ** Whether a severity is at least the minimum (default WARNING). + *******************************************************************************/ + static boolean atLeast(String severity, String minimum) + { + return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0); + } + + + + /******************************************************************************* + ** One email: the notifications' titles, bodies (not for SECURITY) and + ** links. False when it could not be sent (or there is no address). + *******************************************************************************/ + private boolean send(String address, String subject, List items) + { + if(!StringUtils.hasContent(address)) + { + return (false); + } + StringBuilder text = new StringBuilder(); + StringBuilder html = new StringBuilder("
"); + for(QRecord item : items) + { + String title = item.getValueString("title"); + String body = NotificationCategory.SECURITY.name().equals(item.getValueString("category")) ? null : item.getValueString("body"); + String link = absolute(NotificationService.safeLink(item.getValueString("linkUrl"))); + text.append(title).append("\n"); + html.append("

").append(escape(title)).append("

"); + if(StringUtils.hasContent(body)) + { + text.append(body).append("\n"); + html.append("

").append(escape(body)).append("

"); + } + if(link != null) + { + text.append(link).append("\n"); + html.append("

") + .append(escape(StringUtils.hasContent(item.getValueString("linkLabel")) ? item.getValueString("linkLabel") : "Open")).append("

"); + } + text.append("\n"); + } + String settings = absolute("/app/" + AlertsNotificationPreference.TABLE_NAME); + text.append("Change these emails in My Notification Settings: ").append(settings).append("\n"); + html.append("

Change these emails in My Notification Settings.

"); + try + { + new SendMessageAction().execute(new SendMessageInput() + .withMessagingProviderName(runtime.getConfig().getMessagingProviderName()) + .withFrom(new Party().withAddress(runtime.getConfig().getFromAddress()).withRole(EmailPartyRole.FROM)) + .withTo(new MultiParty().withParty(new Party().withAddress(address).withRole(EmailPartyRole.TO))) + .withSubject(subject) + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody(text.toString())) + .withContent(new Content().withContentRole(EmailContentRole.HTML).withBody(html.toString()))); + return (true); + } + catch(Exception e) + { + LOG.warn("Could not email a notification", e, logPair("subject", subject)); + return (false); + } + } + + + + /******************************************************************************* + ** A link made absolute with the public base URL (relative app paths). + *******************************************************************************/ + private String absolute(String link) + { + String base = runtime.getConfig().getPublicBaseUrl(); + if(link == null || !link.startsWith("/") || !StringUtils.hasContent(base)) + { + return (link); + } + return (base.replaceAll("/+$", "") + link); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String escape(String value) + { + return (value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)); + } + + + + /******************************************************************************* + ** The live (SENT, published, not expired) notifications for the rows. + *******************************************************************************/ + private static Map notifications(List recipients, Instant now) throws QException + { + Map live = new HashMap<>(); + List ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList(); + if(ids.isEmpty()) + { + return (live); + } + for(QRecord notification : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids) + .withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT))) + { + Instant expireAt = notification.getValueInstant("expireAt"); + if(expireAt == null || expireAt.isAfter(now)) + { + live.put(notification.getValueInteger("id"), notification); + } + } + return (live); + } + + + + /******************************************************************************* + ** My Alert Settings rows (address, time zone, digest hour) by user. + *******************************************************************************/ + private static Map userSettings(java.util.Set userIds) throws QException + { + Map settings = new HashMap<>(); + for(QRecord setting : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(userIds)))) + { + settings.put(setting.getValueString("userId"), setting); + } + return (settings); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String address(QRecord setting) + { + return (setting == null ? null : setting.getValueString("emailAddress")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void mark(QRecord recipient, EmailStatus status, Instant at) throws QException + { + QRecord update = new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", status.name()); + if(at != null) + { + update.setValue("emailedAt", at); + } + AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, update); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java index ad37ed7..67bc1bb 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -431,7 +431,9 @@ public class NotificationService /******************************************************************************* - ** The link if it is a relative app path or an https URL, else null. + ** The link if it is a relative app path (one leading slash) or an https URL + ** with a host, else null. Backslashes, whitespace and control characters + ** are refused anywhere (browsers treat "/\evil.com" as "//evil.com"). *******************************************************************************/ public static String safeLink(String url) { @@ -440,8 +442,26 @@ public class NotificationService return (null); } String trimmed = url.trim(); - boolean relative = trimmed.startsWith("/") && !trimmed.startsWith("//"); - return (relative || trimmed.toLowerCase().startsWith("https://") ? trimmed : null); + for(char c : trimmed.toCharArray()) + { + if(c == '\\' || Character.isWhitespace(c) || Character.isISOControl(c)) + { + return (null); + } + } + if(trimmed.startsWith("/")) + { + return (trimmed.length() > 1 && trimmed.charAt(1) == '/' ? null : trimmed); + } + try + { + java.net.URI uri = new java.net.URI(trimmed); + return ("https".equalsIgnoreCase(uri.getScheme()) && StringUtils.hasContent(uri.getHost()) ? trimmed : null); + } + catch(java.net.URISyntaxException e) + { + return (null); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java index 1181b35..fe570f8 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java @@ -18,6 +18,7 @@ package com.kingsrook.qbits.alerts.processes; import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.notifications.NotificationEmailer; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; @@ -75,6 +76,7 @@ public final class AlertsScheduledSteps runtime.sink().flush(); runtime.getNotifications().sendDue(); runtime.getNotifications().purge(); + new NotificationEmailer(runtime).run(); output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue()); } } diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java new file mode 100644 index 0000000..1411c77 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * 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 + * + * http://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.alerts.notifications; + + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.kingsrook.qbits.alerts.AlertsPreferences; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsNotification; +import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qbits.alerts.spi.AlertLink; +import com.kingsrook.qbits.alerts.spi.NotificationAudience; +import com.kingsrook.qbits.alerts.spi.NotificationCategory; +import com.kingsrook.qbits.alerts.spi.NotificationSeverity; +import com.kingsrook.qbits.alerts.spi.NotificationSignal; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; +import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Notification email (immediate and daily digest) and the ready-made notices. + *******************************************************************************/ +class NotificationEmailAndNoticesTest extends BaseAlertsTest +{ + static final List SENT = new CopyOnWriteArrayList<>(); + + + + /******************************************************************************* + ** Captures messages instead of sending them. + *******************************************************************************/ + public static class CapturingProvider implements MessagingProviderInterface + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getType() + { + return ("alerts-capture"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public SendMessageOutput sendMessage(SendMessageInput sendMessageInput) + { + SENT.add(sendMessageInput); + return (new SendMessageOutput()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void customizeConfig(AlertsQBitConfig config) + { + QMessagingProviderDispatcher.registerMessagingProvider(new CapturingProvider()); + qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("capture").withType("alerts-capture")); + SENT.clear(); + config.withMessagingProviderName("capture").withFromAddress("alerts@example.com"); + } + + + + /******************************************************************************* + ** A user's email settings: address, time zone and digest hour, and one + ** category preference. + *******************************************************************************/ + private void emailSettings(String userId, String category, String mode, String minSeverity) throws QException + { + AlertsPreferences.ensure(runtime, loginAs(userId)); + runtime.asSystem(() -> + { + QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); + AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", settings.getValue("id")).withValue("emailAddress", userId + "@example.com") + .withValue("timeZone", "UTC").withValue("digestHour", 12)); + AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("category", category) + .withValue("emailMode", mode).withValue("emailMinSeverity", minSeverity)); + return (null); + }); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private String emailStatus(String userId) throws QException + { + return (runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId))).getValueString("emailStatus")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String text(SendMessageInput input) + { + return (input.getContentList().stream().map(Content::getBody).reduce("", String::concat)); + } + + + + /******************************************************************************* + ** Immediate email at or above the minimum severity; below it is skipped; + ** users without email settings are left alone. + *******************************************************************************/ + @Test + void testImmediate() throws Exception + { + emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING"); + emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR"); + runtime.getNotifications().notify(new NotificationSignal("Export failed").withBody("Disk ").withSeverity(NotificationSeverity.WARNING) + .withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1", "viewer-2", "manager-1"))); + + NotificationEmailer.Result result = new NotificationEmailer(runtime).run(); + assertThat(result.sent()).isEqualTo(1); + assertThat(result.skipped()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + { + assertThat(message.getSubject()).isEqualTo("Export failed"); + assertThat(text(message)).contains("Disk <full>").contains("https://admin.example.com/app/widget").contains("My Notification Settings"); + }); + assertThat(emailStatus("viewer-1")).isEqualTo("SENT"); + assertThat(emailStatus("viewer-2")).isEqualTo("SKIPPED"); + assertThat(emailStatus("manager-1")).isEqualTo("NONE"); + assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(0); + } + + + + /******************************************************************************* + ** The daily digest waits for the user's hour, then lists the unread ones. + *******************************************************************************/ + @Test + void testDigest() throws Exception + { + clock.set(Instant.parse("2026-10-12T09:00:00Z")); + emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO"); + AlertsNotices.release("2.4", "Dark mode everywhere.", null); + runtime.getNotifications().notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1"))); + + assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0); + clock.set(Instant.parse("2026-10-12T12:05:00Z")); + assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + { + assertThat(message.getSubject()).isEqualTo("2 new notifications"); + assertThat(text(message)).contains("Version 2.4 is out").contains("Dark mode everywhere.").contains("Version 2.5 is out"); + }); + assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0); + assertThat(NotificationEmailer.isDigestHour(new QRecord().withValue("timeZone", "Not/AZone").withValue("digestHour", 12), clock.instant())).isTrue(); + assertThat(NotificationEmailer.atLeast("CRITICAL", null)).isTrue(); + assertThat(NotificationEmailer.atLeast("INFO", null)).isFalse(); + } + + + + /******************************************************************************* + ** Security notices: no body in email; the notices helpers. + *******************************************************************************/ + @Test + void testNotices() throws Exception + { + emailSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO"); + loginAs("viewer-1"); + AlertsNotices.newSignIn("viewer-1", "Firefox on Windows"); + AlertsNotices.apiKeyCreated("viewer-1", "ci-bot"); + AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z")); + AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z")); + AlertsNotices.permissionChanged("viewer-1", "You were added to the Editors role"); + AlertsNotices.shared("viewer-1", "Sam shared a notebook with you", AlertLink.toPath("/app/widget", "Open"), "qbit-sql-workbench"); + + List notifications = runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME)); + assertThat(notifications).hasSize(5); + assertThat(notifications).filteredOn(n -> "SECURITY".equals(n.getValueString("category"))).hasSize(4); + assertThat(notifications).anyMatch(n -> n.getValueString("title").equals("API key expires Nov 1, 2026: ci-bot")); + + new NotificationEmailer(runtime).run(); + assertThat(SENT).hasSize(4); + assertThat(SENT).noneMatch(message -> text(message).contains("change your password")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java index 2874175..108c214 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java @@ -329,6 +329,12 @@ class NotificationsTest extends BaseAlertsTest assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x"); assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts"); assertThat(NotificationService.safeLink("//evil.example.com")).isNull(); + for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a b", "https://")) + { + assertThat(NotificationService.safeLink(bad)).as(bad).isNull(); + } + assertThat(NotificationService.safeLink(" https://example.com/x ")).isEqualTo("https://example.com/x"); + assertThat(NotificationService.safeLink("/")).isEqualTo("/"); assertThatThrownBy(() -> runtime.getNotifications().notify(new NotificationSignal("No audience"))).hasMessageContaining("audience"); ////////////////////////////////////////////////////////////////////