feat(notifications): security, share and release notices, email digests; harden links (phases d, e) #11

Merged
KofTwentyTwo merged 2 commits from feature/notifications-de into develop 2026-10-13 15:21:20 +00:00
11 changed files with 827 additions and 6 deletions

No files matched your search

+4
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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;
@@ -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, "<p style=\"margin:0\">" + line + "</p>"));
}
/*******************************************************************************
** Delivery health (alerts admins): unhealthy channels and failed or dead
** deliveries in 24 hours; one line when all is well.
@@ -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<String> 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);
}
@@ -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);
}
}
}
@@ -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<String, Map<String, QRecord>> 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<QRecord> 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<Integer, QRecord> notifications = notifications(recipients, now);
Map<String, QRecord> settings = userSettings(preferences.keySet());
int sent = 0;
int skipped = 0;
int failed = 0;
Map<String, List<QRecord[]>> 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<String, List<QRecord[]>> digest : digests.entrySet())
{
QRecord setting = settings.get(digest.getKey());
if(!isDigestHour(setting, now))
{
continue;
}
List<QRecord> 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<QRecord> items)
{
if(!StringUtils.hasContent(address))
{
return (false);
}
StringBuilder text = new StringBuilder();
StringBuilder html = new StringBuilder("<div style=\"font-family:sans-serif\">");
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("<h3 style=\"margin:16px 0 4px\">").append(escape(title)).append("</h3>");
if(StringUtils.hasContent(body))
{
text.append(body).append("\n");
html.append("<p style=\"white-space:pre-wrap;margin:0\">").append(escape(body)).append("</p>");
}
if(link != null)
{
text.append(link).append("\n");
html.append("<p style=\"margin:4px 0\"><a href=\"").append(escape(link)).append("\">")
.append(escape(StringUtils.hasContent(item.getValueString("linkLabel")) ? item.getValueString("linkLabel") : "Open")).append("</a></p>");
}
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("<p style=\"color:#666;font-size:12px;margin-top:24px\">Change these emails in <a href=\"").append(escape(settings)).append("\">My Notification Settings</a>.</p></div>");
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
/*******************************************************************************
** The live (SENT, published, not expired) notifications for the rows.
*******************************************************************************/
private static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
{
Map<Integer, QRecord> live = new HashMap<>();
List<Integer> 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<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
{
Map<String, QRecord> 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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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<SendMessageInput> 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 <full>").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 &lt;full&gt;").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<QRecord> 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"));
}
}
@@ -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");
////////////////////////////////////////////////////////////////////