feat(notifications): email REQUESTED recipients immediately (per-job opt-in), v2 #37

Merged
KofTwentyTwo merged 2 commits from feature/notify-email-requested-v2 into develop 2026-10-14 23:51:04 +00:00
4 changed files with 273 additions and 13 deletions

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ All notable changes to this project are documented here. The format follows Keep
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
- Per-notification email opt-in via recipient email status `REQUESTED`: immediate delivery without a standing category preference. It still honours the category email minimum severity, the user pause (SECURITY bypasses) and quiet hours (held until they end, for up to a day), is skipped when no address is known, and is never emailed for ALERT notifications.
- Optional external channels:
- Slack (an Incoming Webhook, Block Kit)
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
@@ -290,7 +290,7 @@ public final class AlertsPossibleValues
*******************************************************************************/
public enum EmailStatus
{
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
}
@@ -29,11 +29,14 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.email.AlertsEmail;
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
import com.kingsrook.qbits.alerts.engine.AlertTiming;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
@@ -43,6 +46,7 @@ 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.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -67,6 +71,11 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** - 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.
** A recipient with email status REQUESTED (a per-notification opt-in, e.g. by
** a job) is emailed immediately without a standing category preference. It
** still honours the category's email minimum severity when one is set, the
** user's pause (SECURITY bypasses) and quiet hours (held, not dropped), is
** skipped without a known address, and is only eligible for a day.
** Sent through qbit-email when it is produced in the instance (one message
** per recipient; templates alerts_notification and alerts_digest,
** NOTIFICATION), else through core SendMessageAction. Inert without either
@@ -137,12 +146,19 @@ public class NotificationEmailer
{
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
if(preferences.isEmpty())
QQueryFilter eligible = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withSubFilter(new QQueryFilter()
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name())
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
if(!preferences.isEmpty())
{
return (new Result(0, 0, 0, 0));
eligible.withSubFilter(new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())));
}
Map<String, QRecord> settings = userSettings(preferences.keySet());
Map<String, QRecord> settings = new HashMap<>();
int sent = 0;
int skipped = 0;
int failed = 0;
@@ -151,8 +167,7 @@ public class NotificationEmailer
while(true)
{
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()))
.withSubFilter(eligible)
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
if(recipients.isEmpty())
@@ -161,25 +176,43 @@ public class NotificationEmailer
}
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = notifications(recipients, now);
Set<String> pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet());
settings.putAll(userSettings(pageUsers));
Map<String, Map<String, QRecord>> requestedPreferences = new HashMap<>();
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(pageUsers))))
{
requestedPreferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
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")))
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
String category = notification == null ? null : notification.getValueString("category");
QRecord preference = category == null ? null : (requested ? requestedPreferences : preferences).getOrDefault(userId, Map.of()).get(category);
QRecord setting = settings.get(userId);
if(notification == null || NotificationCategory.ALERT.name().equals(category)
|| (requested ? (!StringUtils.hasContent(address(setting)) || (preference != null && StringUtils.hasContent(preference.getValueString("emailMinSeverity"))
&& !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
: (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))))
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
}
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category"))
&& settings.get(userId) != null && settings.get(userId).getValueInstant("pausedUntil") != null
&& settings.get(userId).getValueInstant("pausedUntil").isAfter(now))
else if(!NotificationCategory.SECURITY.name().equals(category)
&& setting != null && setting.getValueInstant("pausedUntil") != null
&& setting.getValueInstant("pausedUntil").isAfter(now))
{
// Leave the recipient pending so a later run can deliver it.
skipped++;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
else if(requested && AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now))
{
// Quiet hours hold a requested email; it stays REQUESTED until the window ends or it ages out.
skipped++;
}
else if(requested || EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
@@ -172,6 +172,232 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
/*******************************************************************************
** A requested email does not require a standing category preference.
*******************************************************************************/
@Test
void testRequestedWithoutPreference() throws Exception
{
addressOnly();
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** An explicit request and an immediate preference still send only once.
*******************************************************************************/
@Test
void testRequestedWithImmediatePreference() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "INFO");
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A request overrides digest timing (not the category's minimum severity).
*******************************************************************************/
@Test
void testRequestedWithDigestPreference() throws Exception
{
emailSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.sent()).isEqualTo(1);
assertThat(result.digests()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** Knowing an address alone does not opt a notification into email.
*******************************************************************************/
@Test
void testNotRequestedWithoutPreference() throws Exception
{
addressOnly();
runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.user("viewer-1")));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("NONE");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Missing addresses are skipped rather than counted as delivery failures.
*******************************************************************************/
@Test
void testRequestedWithoutAddress() throws Exception
{
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(result.failed()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Alert subscriptions own alert emails, even with an explicit request.
*******************************************************************************/
@Test
void testRequestedAlertNeverEmailed() throws Exception
{
emailSettings("viewer-1", "ALERT", "IMMEDIATE", "INFO");
requested("ALERT");
assertThat(new NotificationEmailer(runtime).run().skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Requests older than the delivery window are not emailed.
*******************************************************************************/
@Test
void testRequestedOutsideDeliveryWindow() throws Exception
{
addressOnly();
requested("JOB");
clock.set(clock.instant().plusSeconds(86401));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Provider failures remain terminal and do not cause repeated sends.
*******************************************************************************/
@Test
void testRequestedDeliveryFailure() throws Exception
{
addressOnly();
requested("JOB");
runtime.getConfig().withMessagingProviderName("missing-provider");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.failed()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("FAILED");
assertThat(new NotificationEmailer(runtime).run().failed()).isZero();
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A request still honours the category's email minimum severity.
*******************************************************************************/
@Test
void testRequestedBelowMinSeverity() throws Exception
{
emailSettings("viewer-1", "JOB", "OFF", "ERROR");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A user's pause or quiet hours hold a requested email until they end.
*******************************************************************************/
@Test
void testRequestedHeldByPauseAndQuietHours() throws Exception
{
clock.set(Instant.parse("2026-10-12T12:00:00Z"));
addressOnly();
requested("JOB");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T13:30:00Z"));
setUserPreference(new QRecord().withValue("timeZone", "UTC").withValue("quietHoursStart", "13:00").withValue("quietHoursEnd", "14:00"));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T14:00:00Z"));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A pause does not hold a requested SECURITY email.
*******************************************************************************/
@Test
void testRequestedSecurityBypassesPause() throws Exception
{
addressOnly();
requested("SECURITY");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
}
/*******************************************************************************
** Update the user's alert settings row.
*******************************************************************************/
private void setUserPreference(QRecord values) throws QException
{
runtime.asSystem(() ->
{
QRecord setting = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1"));
return (AlertsDb.update(AlertsUserPreference.TABLE_NAME, values.withValue("id", setting.getValue("id"))));
});
}
/*******************************************************************************
** Store an address without opting into any category.
*******************************************************************************/
private void addressOnly() throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME,
new QRecord().withValue("userId", "viewer-1").withValue("emailAddress", "viewer-1@example.com")));
}
/*******************************************************************************
** Set up a recipient explicitly requesting email for worker tests.
*******************************************************************************/
private void requested(String category) throws QException
{
Integer id = runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.valueOf(category))
.withAudience(NotificationAudience.user("viewer-1")));
runtime.asSystem(() ->
{
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id));
return (AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME,
new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", "REQUESTED")));
});
}
/*******************************************************************************
** The daily digest waits for the user's hour, then lists the unread ones.
*******************************************************************************/