diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ee3b6..e75c416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java index fb799ee..ca5270a 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -290,7 +290,7 @@ public final class AlertsPossibleValues *******************************************************************************/ public enum EmailStatus { - NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED + NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java index d9f6fac..3feb950 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -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 settings = userSettings(preferences.keySet()); + Map settings = new HashMap<>(); int sent = 0; int skipped = 0; int failed = 0; @@ -151,8 +167,7 @@ public class NotificationEmailer while(true) { List recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter() - .withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet())) - .withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())) + .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 notifications = notifications(recipients, now); + Set pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet()); + settings.putAll(userSettings(pageUsers)); + Map> 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); diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java index 2959f5e..0b59e98 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java @@ -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. *******************************************************************************/