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