feat(notifications): security, share and release notices, email digests; harden links (phases d, e) #11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-1
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
}
|
||||
|
||||
+220
@@ -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 <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<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");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
|
||||
Reference in new issue
Block a user