diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b2305d..d65099d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed +- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.1): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present. - Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override). - Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build. - Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing. diff --git a/README.md b/README.md index 24560c2..e60f32d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano - QQQ `4.1.0-RC.6` (released on Maven Central). - An RDBMS backend. - A scheduler, for monitors and email delivery. -- A messaging provider, for email. +- For email, either [qbit-email](https://github.com/QRun-IO/qbit-email) produced in the same instance, or a messaging provider. - `qqq-middleware-javalin`, for the `/qqq/v1/alerts` routes (optional). The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route @@ -155,6 +155,18 @@ How each one sends: - **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently. - **URLs** must be https, unless the channel's settings say `"allowInsecureHttp": true`, which is meant for local receivers. +## Email through qbit-email + +When qbit-email is produced in the same instance (and on the classpath; it is an optional dependency), alert channel emails, notification emails and daily digests go through `EmailService` instead of core `SendMessageAction`: + +- **Templates**, registered on first use and never overwritten (edit them in the Email app): `alerts_alert` (a channel email for one alert or a batch), `alerts_notification` (one notification) and `alerts_digest` (a list of items, `{{#each items}}`). All are Markdown, category NOTIFICATION, so unsubscribe and suppression apply. +- **One recipient per message.** An email list channel sends one message per address. +- **Idempotency keys** never hold an address: `alerts:alert::::` (the kind and minute keep a reminder or a reopened alert from looking like a repeat), `alerts:notification::` and `alerts:digest::`. +- **Linked record** is the alert or notification row; the sender is `qbit-alerts` and the process `alertsDeliverNotifications`. +- **Outcomes.** Queued or duplicate is sent. Suppressed (the person unsubscribed) is `SKIPPED` for notifications and a permanent failure that says so for channels. Refused (for example no enabled email provider) is `FAILED` and not retried. +- **Text** in the variables is the existing redacted, external-safe text. No record labels are passed. +- **Config.** `messagingProviderName` and `fromAddress` are not needed (the From address comes from the email provider). Without qbit-email they work as before. + ## Known limits (v1) - **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the two scheduled processes on one node, or accept at-least-once delivery. diff --git a/pom.xml b/pom.xml index 39b553f..e096d3b 100644 --- a/pom.xml +++ b/pom.xml @@ -22,8 +22,15 @@ 21 21 0.8.15 + + 0.1.0-RC.1 true true + + + + com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email + com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email @@ -82,6 +89,15 @@ true + + + + com.kingsrook.qbits + qbit-email + ${qbit-email.version} + true + + com.kingsrook.qqq qqq-backend-module-rdbms diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java index 3a1eb22..da247f8 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java @@ -18,10 +18,15 @@ package com.kingsrook.qbits.alerts.delivery; import java.io.Serializable; +import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.email.AlertsEmail; +import com.kingsrook.qbits.alerts.email.AlertsEmailSender; +import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; @@ -40,8 +45,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; ** list (EMAIL_LIST: settings {"addresses": ["ops@example.com", ...]}), sent ** through QQQ's SendMessageAction and the configured messaging provider ** (SMTP or SES). Plain text plus simple HTML, with a link to change or stop - ** the emails. Without a messaging provider the channel fails permanently and - ** says why. + ** the emails. When qbit-email is produced in the instance, each recipient gets + ** their own message through it (template alerts_alert, NOTIFICATION); the + ** messaging provider is then not needed. Without either, the channel fails + ** permanently and says why. *******************************************************************************/ public class EmailChannel implements AlertChannelInterface { @@ -79,13 +86,16 @@ public class EmailChannel implements AlertChannelInterface public List validate(QRecord channel, AlertSecretResolver secrets) { List problems = new ArrayList<>(); - if(!StringUtils.hasContent(config.getMessagingProviderName())) + if(!AlertsEmail.isActive()) { - problems.add("Email is not configured: set messagingProviderName in the Alerts QBit config."); - } - if(!StringUtils.hasContent(config.getFromAddress())) - { - problems.add("Email has no From address: set fromAddress in the Alerts QBit config."); + if(!StringUtils.hasContent(config.getMessagingProviderName())) + { + problems.add("Email is not configured: set messagingProviderName in the Alerts QBit config."); + } + if(!StringUtils.hasContent(config.getFromAddress())) + { + problems.add("Email has no From address: set fromAddress in the Alerts QBit config."); + } } if(ChannelType.EMAIL_LIST.name().equals(type) && listAddresses(channel).isEmpty()) { @@ -114,6 +124,11 @@ public class EmailChannel implements AlertChannelInterface return (DeliveryResult.failed("No email address.")); } + if(AlertsEmail.isActive()) + { + return (deliverThroughEmailQBit(message, addresses)); + } + try { MultiParty to = new MultiParty(); @@ -138,6 +153,75 @@ public class EmailChannel implements AlertChannelInterface + /******************************************************************************* + ** One qbit-email message per address. A retry is safe: the idempotency key + ** makes the already-queued ones DUPLICATE. Refused is permanent; + ** suppressed (the person unsubscribed) is reported, not retried. + *******************************************************************************/ + private DeliveryResult deliverThroughEmailQBit(AlertMessage message, List addresses) + { + List> items = new ArrayList<>(); + for(AlertMessage.Entry entry : message.entries()) + { + Map item = new LinkedHashMap<>(); + put(item, "title", entry.title()); + put(item, "severity", entry.severity()); + put(item, "typeLabel", entry.typeLabel()); + put(item, "group", entry.group()); + put(item, "summary", entry.summary()); + put(item, "itemCount", entry.itemCount()); + items.add(item); + } + String base = StringUtils.hasContent(config.getPublicBaseUrl()) ? config.getPublicBaseUrl().replaceAll("/+$", "") : ""; + Instant now = config.getRuntime() == null ? Instant.now() : config.getRuntime().now(); + String process = AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS; + String problem = null; + int suppressed = 0; + Integer firstId = null; + try + { + for(String address : addresses) + { + AlertsEmailSender.Result result = AlertsEmailSender.sendAlert(address, process, message.subject(), message.severity(), message.kind(), items, message.link(), + base + "/app/alerts_subscription", message.alertIds(), now); + firstId = firstId == null ? result.messageId() : firstId; + if(result.status() == AlertsEmailSender.Status.REFUSED && problem == null) + { + problem = AlertRedactor.redact(result.detail()); + } + suppressed += result.status() == AlertsEmailSender.Status.SUPPRESSED ? 1 : 0; + } + } + catch(Exception e) + { + return (DeliveryResult.retry(AlertRedactor.redact(e.getClass().getSimpleName() + ": " + e.getMessage()))); + } + if(problem != null) + { + return (DeliveryResult.failed("Email was refused: " + problem)); + } + if(suppressed == addresses.size()) + { + return (DeliveryResult.failed("Email was suppressed: the recipient has unsubscribed.")); + } + return (DeliveryResult.sent(firstId == null ? null : String.valueOf(firstId))); + } + + + + /******************************************************************************* + ** Put a value in a template item unless it is null. + *******************************************************************************/ + private static void put(Map item, String name, Serializable value) + { + if(value != null) + { + item.put(name, value); + } + } + + + /******************************************************************************* ** The addresses in an email list channel's settings. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmail.java b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmail.java new file mode 100644 index 0000000..3bbb4cc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmail.java @@ -0,0 +1,75 @@ +/* + * 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.email; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** The guard for sending through qbit-email. qbit-email is an optional + ** dependency: this class (which names no qbit-email class) answers whether the + ** email qbit is on the classpath AND produced in the current instance. Only + ** then does the rest of the qbit call {@link AlertsEmailSender}; otherwise + ** email goes through core SendMessageAction and messagingProviderName. + *******************************************************************************/ +public final class AlertsEmail +{ + private static final String EMAIL_SERVICE_CLASS = "com.kingsrook.qbits.email.outbox.EmailService"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AlertsEmail() + { + } + + + + /******************************************************************************* + ** Whether email goes through qbit-email in the current instance. + *******************************************************************************/ + public static boolean isActive() + { + return (isActive(QContext.getQInstance())); + } + + + + /******************************************************************************* + ** Whether email goes through qbit-email in an instance. + *******************************************************************************/ + public static boolean isActive(QInstance qInstance) + { + if(qInstance == null) + { + return (false); + } + try + { + Class.forName(EMAIL_SERVICE_CLASS); + } + catch(ClassNotFoundException | LinkageError e) + { + return (false); + } + return (AlertsEmailSender.isInstalled(qInstance)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java new file mode 100644 index 0000000..7a73c0c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java @@ -0,0 +1,331 @@ +/* + * 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.email; + + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import com.kingsrook.qbits.alerts.model.AlertsAlert; +import com.kingsrook.qbits.alerts.model.AlertsNotification; +import com.kingsrook.qbits.email.EmailRuntime; +import com.kingsrook.qbits.email.outbox.EmailCategory; +import com.kingsrook.qbits.email.outbox.EmailRequest; +import com.kingsrook.qbits.email.outbox.EmailSendResult; +import com.kingsrook.qbits.email.outbox.EmailService; +import com.kingsrook.qbits.email.template.EmailTemplateDefinition; +import com.kingsrook.qbits.email.template.EmailTemplates; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** The one place that names qbit-email classes. Callers reach it only after + ** {@link AlertsEmail#isActive()}. It registers the alerts templates on first + ** use (never overwriting people's edits) and sends one message per recipient + ** through {@code EmailService}, all in the NOTIFICATION category: + ** - alerts_alert: an alert channel email (one alert or a batch); + ** - alerts_notification: one user notification; + ** - alerts_digest: a user's daily digest of notifications. + ** Titles, summaries and bodies passed as variables are the existing + ** redacted, external-safe text. No record labels are passed. + *******************************************************************************/ +public final class AlertsEmailSender +{ + public static final String SENDER_QBIT = "qbit-alerts"; + + public static final String TEMPLATE_ALERT = "alerts_alert"; + public static final String TEMPLATE_NOTIFICATION = "alerts_notification"; + public static final String TEMPLATE_DIGEST = "alerts_digest"; + + + + /******************************************************************************* + ** The outcome of one send, in this qbit's own terms. + ** + ** @param status what qbit-email did + ** @param messageId the email_message id (null when refused) + ** @param detail plain reasons (refusals), or null + *******************************************************************************/ + public record Result(Status status, Integer messageId, String detail) + { + } + + + + /******************************************************************************* + ** Mirrors qbit-email's send statuses. + *******************************************************************************/ + public enum Status + { + QUEUED, DUPLICATE, SUPPRESSED, REFUSED + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AlertsEmailSender() + { + } + + + + /******************************************************************************* + ** Whether the email qbit is produced in the instance. + *******************************************************************************/ + static boolean isInstalled(QInstance qInstance) + { + return (EmailRuntime.config(qInstance) != null); + } + + + + /******************************************************************************* + ** An alert channel email to one recipient. The key carries the message kind + ** and the minute so a reminder, or an alert that reopens, is not mistaken + ** for a repeat of the first email. + ** + ** @param to the address + ** @param process the sending process name + ** @param subject the rendered one-line subject + ** @param severity the highest severity + ** @param messageKind RAISED, RESOLVED, REMINDER, ESCALATED, DIGEST or TEST + ** @param items one map per alert: title, severity, typeLabel, group, summary, itemCount + ** @param link the alert (or list) in the app, when absolute + ** @param settingsUrl where to change or stop the emails, when absolute + ** @param alertIds the alerts in the message + ** @param now the time, to tell repeats apart + *******************************************************************************/ + public static Result sendAlert(String to, String process, String subject, String severity, String messageKind, List> items, String link, String settingsUrl, + List alertIds, Instant now) throws QException + { + ensure(TEMPLATE_ALERT); + List ids = alertIds == null ? List.of() : alertIds.stream().filter(id -> id != null).toList(); + Integer first = ids.isEmpty() ? null : ids.get(0); + String idPart = ids.isEmpty() ? "test" : String.join("-", ids.stream().map(String::valueOf).toList()); + String key = "alerts:alert:" + idPart + ":" + hash(to) + ":" + messageKind + ":" + (now.getEpochSecond() / 60); + + EmailRequest request = base(to, process, key) + .withTemplateName(TEMPLATE_ALERT) + .withVariable("subject", subject) + .withVariable("severity", severity) + .withVariable("kind", messageKind) + .withVariable("items", (Serializable) items); + if(first != null) + { + request.withLinkedRecord(AlertsAlert.TABLE_NAME, first); + } + withUrl(request, "link", link); + withUrl(request, "settings_url", settingsUrl); + return (result(EmailService.send(request))); + } + + + + /******************************************************************************* + ** One user notification to one recipient. + ** + ** @param to the address + ** @param process the sending process name + ** @param notificationId the notification row + ** @param item title, body (absent for SECURITY), link and linkLabel + ** @param settingsUrl where to change these emails, when absolute + *******************************************************************************/ + public static Result sendNotification(String to, String process, Integer notificationId, Map item, String settingsUrl) throws QException + { + ensure(TEMPLATE_NOTIFICATION); + EmailRequest request = base(to, process, "alerts:notification:" + notificationId + ":" + hash(to)) + .withTemplateName(TEMPLATE_NOTIFICATION) + .withLinkedRecord(AlertsNotification.TABLE_NAME, notificationId); + for(Map.Entry entry : item.entrySet()) + { + if("link".equals(entry.getKey())) + { + withUrl(request, "link", (String) entry.getValue()); + } + else + { + request.withVariable(entry.getKey(), entry.getValue()); + } + } + withUrl(request, "settings_url", settingsUrl); + return (result(EmailService.send(request))); + } + + + + /******************************************************************************* + ** A user's digest. + ** + ** @param to the address + ** @param process the sending process name + ** @param userId the user + ** @param windowStart the start of the digest window (the digest hour) + ** @param firstNotificationId the notification row the message links to + ** @param items one map per notification: title, body, link, linkLabel + ** @param settingsUrl where to change these emails, when absolute + *******************************************************************************/ + public static Result sendDigest(String to, String process, String userId, Instant windowStart, Integer firstNotificationId, List> items, + String settingsUrl) throws QException + { + ensure(TEMPLATE_DIGEST); + EmailRequest request = base(to, process, "alerts:digest:" + userId + ":" + windowStart) + .withTemplateName(TEMPLATE_DIGEST) + .withVariable("count", items.size()) + .withVariable("items", (Serializable) items) + .withLinkedRecord(AlertsNotification.TABLE_NAME, firstNotificationId); + withUrl(request, "settings_url", settingsUrl); + return (result(EmailService.send(request))); + } + + + + /******************************************************************************* + ** The parts every message has: one recipient, the sender and the key. + *******************************************************************************/ + private static EmailRequest base(String to, String process, String idempotencyKey) + { + return (new EmailRequest().withTo(to).withCategory(EmailCategory.NOTIFICATION).withSender(SENDER_QBIT, process).withIdempotencyKey(idempotencyKey)); + } + + + + /******************************************************************************* + ** Pass a URL only when it is absolute http(s); the template schema refuses + ** relative links, and they are useless in a mail anyway. + *******************************************************************************/ + private static void withUrl(EmailRequest request, String name, String url) + { + if(url != null && (url.startsWith("https://") || url.startsWith("http://"))) + { + request.withVariable(name, url); + } + } + + + + /******************************************************************************* + ** Translate qbit-email's result. + *******************************************************************************/ + private static Result result(EmailSendResult sent) + { + String detail = sent.reasons() == null || sent.reasons().isEmpty() ? null : String.join("; ", sent.reasons()); + return (new Result(Status.valueOf(sent.status().name()), sent.messageId(), detail)); + } + + + + /******************************************************************************* + ** A short, stable, non-reversible id for an address (for idempotency keys, + ** so no address is stored in them). + *******************************************************************************/ + static String hash(String address) + { + try + { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(address.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for(int i = 0; i < 8; i++) + { + hex.append(String.format("%02x", digest[i])); + } + return (hex.toString()); + } + catch(NoSuchAlgorithmException e) + { + throw (new IllegalStateException(e)); + } + } + + + + /******************************************************************************* + ** Register the template when it does not exist yet (people's edits are + ** never overwritten). + *******************************************************************************/ + private static void ensure(String name) throws QException + { + EmailTemplates.ensure(switch(name) + { + case TEMPLATE_ALERT -> new EmailTemplateDefinition() + .withName(TEMPLATE_ALERT).withLabel("Alerts: alert").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT) + .withSubject("{{subject}}") + .withBody(""" + {{#each items}}**{{this.title}}** + + {{this.severity}} - {{this.typeLabel}}{{#if this.group}} / {{this.group}}{{/if}} + + {{#if this.summary}}{{this.summary}} + + {{/if}}{{#if this.itemCount}}{{this.itemCount}} records + + {{/if}}{{/each}}{{#if link}}[Open in Alerts]({{link}}){.button} + + {{/if}}{{#if settings_url}}[Change or stop these emails]({{settings_url}}) + + {{/if}}Personal data and secrets are removed from alert messages.""") + .withVariable("subject", "Subject", "STRING", true, null) + .withVariable("severity", "Highest severity", "STRING", false, null) + .withVariable("kind", "Message kind", "STRING", false, null) + .withVariable("items", "Alerts", "LIST", true, null) + .withVariable("link", "Link", "URL", false, null) + .withVariable("settings_url", "Settings link", "URL", false, null) + .withSampleVariables("{\"subject\":\"[ERROR] Export failed\",\"severity\":\"ERROR\",\"kind\":\"RAISED\",\"items\":[{\"title\":\"Export failed\",\"severity\":\"ERROR\"," + + "\"typeLabel\":\"Jobs\",\"summary\":\"3 records\"}],\"link\":\"https://example.com/app/alerts_alert/1\",\"settings_url\":\"https://example.com/app/alerts_subscription\"}"); + case TEMPLATE_NOTIFICATION -> new EmailTemplateDefinition() + .withName(TEMPLATE_NOTIFICATION).withLabel("Alerts: notification").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT) + .withSubject("{{title}}") + .withBody(""" + {{#if body}}{{body}} + + {{/if}}{{#if link}}[{{linkLabel|Open}}]({{link}}){.button} + + {{/if}}{{#if settings_url}}Change these emails in [My Notification Settings]({{settings_url}}).{{/if}}""") + .withVariable("title", "Title", "STRING", true, null) + .withVariable("body", "Body", "STRING", false, null) + .withVariable("link", "Link", "URL", false, null) + .withVariable("linkLabel", "Link label", "STRING", false, null) + .withVariable("settings_url", "Settings link", "URL", false, null) + .withSampleVariables("{\"title\":\"Export failed\",\"body\":\"The nightly export did not finish.\",\"link\":\"https://example.com/app/widget\",\"linkLabel\":\"Open\"," + + "\"settings_url\":\"https://example.com/app/alerts_notification_preference\"}"); + case TEMPLATE_DIGEST -> new EmailTemplateDefinition() + .withName(TEMPLATE_DIGEST).withLabel("Alerts: daily digest").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT) + .withSubject("{{count}} new notifications") + .withBody(""" + {{#each items}}**{{this.title}}** + + {{#if this.body}}{{this.body}} + + {{/if}}{{#if this.link}}[{{this.linkLabel|Open}}]({{this.link}}) + + {{/if}}{{/each}}{{#if settings_url}}Change these emails in [My Notification Settings]({{settings_url}}).{{/if}}""") + .withVariable("count", "Notification count", "NUMBER", true, null) + .withVariable("items", "Notifications", "LIST", true, null) + .withVariable("settings_url", "Settings link", "URL", false, null) + .withSampleVariables("{\"count\":2,\"items\":[{\"title\":\"Version 2.5 is out\",\"body\":\"Dark mode everywhere.\",\"link\":\"https://example.com/app/widget\"," + + "\"linkLabel\":\"Open\"},{\"title\":\"Export failed\"}],\"settings_url\":\"https://example.com/app/alerts_notification_preference\"}"); + default -> throw (new IllegalArgumentException("Unknown template " + name)); + }); + } +} 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 6bce0b8..ba2b4f0 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -18,6 +18,7 @@ package com.kingsrook.qbits.alerts.notifications; +import java.io.Serializable; import java.time.DateTimeException; import java.time.Instant; import java.time.ZoneId; @@ -30,7 +31,11 @@ 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.delivery.AlertRedactor; +import com.kingsrook.qbits.alerts.email.AlertsEmail; +import com.kingsrook.qbits.alerts.email.AlertsEmailSender; import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer; import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; @@ -61,7 +66,10 @@ 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. - ** Inert without a messaging provider and a From address. ALERT + ** 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 + ** (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. @@ -100,6 +108,10 @@ public class NotificationEmailer *******************************************************************************/ public boolean isConfigured() { + if(AlertsEmail.isActive()) + { + return (true); + } AlertsQBitConfig config = runtime.getConfig(); return (StringUtils.hasContent(config.getMessagingProviderName()) && StringUtils.hasContent(config.getFromAddress())); } @@ -154,16 +166,11 @@ public class NotificationEmailer } 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++; - } + EmailStatus status = sendImmediate(address(settings.get(userId)), notification); + mark(recipient, status, status == EmailStatus.SENT ? now : null); + sent += status == EmailStatus.SENT ? 1 : 0; + skipped += status == EmailStatus.SKIPPED ? 1 : 0; + failed += status == EmailStatus.FAILED ? 1 : 0; } else if(recipient.getValue("readAt") == null) { @@ -180,13 +187,14 @@ public class NotificationEmailer continue; } List items = digest.getValue().stream().map(pair -> pair[1]).toList(); - boolean ok = send(address(setting), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items); + Instant window = now.atZone(zone(setting)).truncatedTo(ChronoUnit.HOURS).toInstant(); + EmailStatus status = sendDigest(address(setting), digest.getKey(), window, items); for(QRecord[] pair : digest.getValue()) { - mark(pair[0], ok ? EmailStatus.DIGESTED : EmailStatus.FAILED, ok ? now : null); + mark(pair[0], status == EmailStatus.SENT ? EmailStatus.DIGESTED : status, status == EmailStatus.SENT ? now : null); } - digestCount += ok ? 1 : 0; - failed += ok ? 0 : 1; + digestCount += status == EmailStatus.SENT ? 1 : 0; + failed += status == EmailStatus.FAILED ? 1 : 0; } return (new Result(sent, digestCount, skipped, failed)); })); @@ -200,19 +208,28 @@ public class NotificationEmailer static boolean isDigestHour(QRecord setting, Instant now) { Integer hour = setting == null ? null : setting.getValueInteger("digestHour"); - ZoneId zone = ZoneOffset.UTC; + return (now.atZone(zone(setting)).getHour() == (hour == null ? 8 : hour)); + } + + + + /******************************************************************************* + ** The user time zone (default UTC). + *******************************************************************************/ + private static ZoneId zone(QRecord setting) + { try { if(setting != null && StringUtils.hasContent(setting.getValueString("timeZone"))) { - zone = ZoneId.of(setting.getValueString("timeZone")); + return (ZoneId.of(setting.getValueString("timeZone"))); } } catch(DateTimeException e) { - zone = ZoneOffset.UTC; + return (ZoneOffset.UTC); } - return (now.atZone(zone).getHour() == (hour == null ? 8 : hour)); + return (ZoneOffset.UTC); } @@ -228,8 +245,126 @@ public class NotificationEmailer /******************************************************************************* - ** One email: the notifications' titles, bodies (not for SECURITY) and - ** links. False when it could not be sent (or there is no address). + ** One notification emailed to one user: SENT, SKIPPED (the person + ** unsubscribed) or FAILED. + *******************************************************************************/ + private EmailStatus sendImmediate(String address, QRecord notification) + { + if(!StringUtils.hasContent(address)) + { + return (EmailStatus.FAILED); + } + if(!AlertsEmail.isActive()) + { + return (send(address, notification.getValueString("title"), List.of(notification)) ? EmailStatus.SENT : EmailStatus.FAILED); + } + try + { + AlertsEmailSender.Result result = AlertsEmailSender.sendNotification(address, AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS, notification.getValueInteger("id"), + item(notification), settingsLink()); + return (status(result)); + } + catch(Exception e) + { + LOG.warn("Could not email a notification", e, logPair("notificationId", notification.getValue("id"))); + return (EmailStatus.FAILED); + } + } + + + + /******************************************************************************* + ** A user digest emailed: SENT, SKIPPED (the person unsubscribed) or FAILED. + *******************************************************************************/ + private EmailStatus sendDigest(String address, String userId, Instant windowStart, List notifications) + { + if(!StringUtils.hasContent(address)) + { + return (EmailStatus.FAILED); + } + if(!AlertsEmail.isActive()) + { + return (send(address, notifications.size() == 1 ? "1 new notification" : notifications.size() + " new notifications", notifications) ? EmailStatus.SENT : EmailStatus.FAILED); + } + try + { + List> items = new ArrayList<>(); + for(QRecord notification : notifications) + { + items.add(item(notification)); + } + return (status(AlertsEmailSender.sendDigest(address, AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS, userId, windowStart, notifications.get(0).getValueInteger("id"), + items, settingsLink()))); + } + catch(Exception e) + { + LOG.warn("Could not email a digest", e, logPair("userId", userId)); + return (EmailStatus.FAILED); + } + } + + + + /******************************************************************************* + ** The qbit-email answer in this qbit email status vocabulary. A repeat + ** (DUPLICATE) was already queued, so it counts as sent. + *******************************************************************************/ + private static EmailStatus status(AlertsEmailSender.Result result) + { + return (switch(result.status()) + { + case QUEUED, DUPLICATE -> EmailStatus.SENT; + case SUPPRESSED -> EmailStatus.SKIPPED; + case REFUSED -> + { + LOG.warn("Email was refused", logPair("reasons", AlertRedactor.redact(result.detail()))); + yield (EmailStatus.FAILED); + } + }); + } + + + + /******************************************************************************* + ** The template variables for one notification: title, body (not for + ** SECURITY), and an absolute link with its label. + *******************************************************************************/ + private Map item(QRecord notification) + { + Map item = new LinkedHashMap<>(); + item.put("title", notification.getValueString("title")); + String body = NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) ? null : notification.getValueString("body"); + if(StringUtils.hasContent(body)) + { + item.put("body", body); + } + String link = absolute(NotificationService.safeLink(notification.getValueString("linkUrl"))); + if(link != null && (link.startsWith("https://") || link.startsWith("http://"))) + { + item.put("link", link); + if(StringUtils.hasContent(notification.getValueString("linkLabel"))) + { + item.put("linkLabel", notification.getValueString("linkLabel")); + } + } + return (item); + } + + + + /******************************************************************************* + ** Where a person changes these emails. + *******************************************************************************/ + private String settingsLink() + { + return (absolute("/app/" + AlertsNotificationPreference.TABLE_NAME)); + } + + + + /******************************************************************************* + ** One email through core SendMessageAction: the notification titles, + ** bodies (not for SECURITY) and links. False when it could not be sent. *******************************************************************************/ private boolean send(String address, String subject, List items) { diff --git a/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java b/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java new file mode 100644 index 0000000..ff90006 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java @@ -0,0 +1,395 @@ +/* + * 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.email; + + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.Statement; +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.alerts.AlertsPreferences; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.delivery.AlertMessage; +import com.kingsrook.qbits.alerts.delivery.DeliveryResult; +import com.kingsrook.qbits.alerts.delivery.EmailChannel; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qbits.alerts.notifications.NotificationEmailer; +import com.kingsrook.qbits.email.EmailQBitConfig; +import com.kingsrook.qbits.email.EmailQBitProducer; +import com.kingsrook.qbits.email.EmailRuntime; +import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Email through qbit-email: with the email qbit produced on the same H2 + ** instance, alert channel emails, notification emails and digests become + ** email_message rows (one recipient each, the right template, category, + ** idempotency key and linked record); a repeat is a duplicate; no provider + ** is a refusal; an unsubscribed address is skipped. + *******************************************************************************/ +class EmailQBitSendingTest extends BaseAlertsTest +{ + /******************************************************************************* + ** The email qbit in the same instance, and the alerts config without a + ** messaging provider (qbit-email makes it unnecessary). + *******************************************************************************/ + @Override + protected void customizeInstance(QInstance qInstance) + { + try + { + new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName(BACKEND_NAME).withPublicBaseUrl("https://admin.example.com")).produce(qInstance); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + + + + /******************************************************************************* + ** The email tables, next to the alerts ones (a clean database each test). + *******************************************************************************/ + @Override + protected void primeDatabase() throws Exception + { + try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement()) + { + statement.execute("DROP ALL OBJECTS"); + } + super.primeDatabase(); + String script; + try(InputStream in = EmailQBitSendingTest.class.getClassLoader().getResourceAsStream("email-h2.sql")) + { + script = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement()) + { + for(String sql : script.split(";")) + { + if(!sql.isBlank()) + { + statement.execute(sql); + } + } + } + } + + + + /******************************************************************************* + ** An enabled default provider (messages are queued, not sent, in these tests). + *******************************************************************************/ + private void addProvider() throws Exception + { + runtime.asSystem(() -> AlertsDb.insert(EmailTables.PROVIDER, new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP") + .withValue("host", "127.0.0.1").withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com") + .withValue("enabled", true).withValue("isDefault", true))); + } + + + + /******************************************************************************* + ** The queued messages, oldest first. + *******************************************************************************/ + private List messages() throws QException + { + return (runtime.asSystem(() -> AlertsDb.query(EmailTables.MESSAGE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withOrderBy( + new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id"))))); + } + + + + /******************************************************************************* + ** The name of a message's template. + *******************************************************************************/ + private String templateName(QRecord message) throws QException + { + return (runtime.asSystem(() -> AlertsDb.byId(EmailTables.TEMPLATE, message.getValue("templateId"))).getValueString("name")); + } + + + + /******************************************************************************* + ** A user's email settings and one category preference (as the + ** notification email tests do). + *******************************************************************************/ + private void emailSettings(String userId, String category, String mode) 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", "INFO")); + return (null); + }); + } + + + + /******************************************************************************* + ** An alert message for alert 5. + *******************************************************************************/ + private static AlertMessage alertMessage() + { + return (new AlertMessage("[ERROR] Export failed", "text", "

html

", "ERROR", "RAISED", List.of(5), "https://admin.example.com/app/alerts_alert/5", + List.of(new AlertMessage.Entry(5, "jobs.export", "Jobs", "k", "ERROR", "OPEN", "Export failed", "3 records failed", "Nightly", 3, + "https://admin.example.com/app/alerts_alert/5", "2026-10-12T12:00:00Z", null)))); + } + + + + /******************************************************************************* + ** The qbit is detected, and the email channel needs no messaging provider. + *******************************************************************************/ + @Test + void testActiveWithoutMessagingProvider() throws Exception + { + assertThat(AlertsEmail.isActive()).isTrue(); + assertThat(AlertsEmail.isActive(null)).isFalse(); + assertThat(EmailRuntime.config(qInstance)).isNotNull(); + assertThat(new EmailChannel(new AlertsQBitConfig(), ChannelType.EMAIL).validate(null, null)).isEmpty(); + assertThat(new NotificationEmailer(runtime).isConfigured()).isTrue(); + } + + + + /******************************************************************************* + ** An alert email: one message, the alerts_alert template, NOTIFICATION, the + ** recipient, an idempotency key without the address, the alert as the linked + ** record, and the sender. Sending it again does not queue a second one. + *******************************************************************************/ + @Test + void testAlertChannelEmail() throws Exception + { + addProvider(); + EmailChannel channel = new EmailChannel(config, ChannelType.EMAIL); + assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT); + + List messages = messages(); + assertThat(messages).hasSize(1); + QRecord message = messages.get(0); + assertThat(templateName(message)).isEqualTo("alerts_alert"); + assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(message.getValueString("toAddresses")).isEqualTo("ops@example.com"); + assertThat(message.getValueString("subject")).isEqualTo("[ERROR] Export failed"); + assertThat(message.getValueString("idempotencyKey")).startsWith("alerts:alert:5:").doesNotContain("ops@example.com").contains(":RAISED:"); + assertThat(message.getValueString("linkedTableName")).isEqualTo("alerts_alert"); + assertThat(message.getValueString("linkedRecordId")).isEqualTo("5"); + assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-alerts"); + assertThat(message.getValueString("senderProcess")).isEqualTo("alertsDeliverNotifications"); + assertThat(message.getValueString("htmlBody")).contains("Export failed").contains("3 records").contains("https://admin.example.com/app/alerts_alert/5"); + + assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT); + assertThat(messages()).hasSize(1); + + clock.advance(java.time.Duration.ofMinutes(10)); + assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT); + assertThat(messages()).hasSize(2); + } + + + + /******************************************************************************* + ** A list channel sends one message per address. + *******************************************************************************/ + @Test + void testEmailListIsOneMessagePerAddress() throws Exception + { + addProvider(); + QRecord list = new QRecord().withValue("settings", "{\"addresses\": [\"ops@example.com\", \"dev@example.com\"]}"); + assertThat(new EmailChannel(config, ChannelType.EMAIL_LIST).deliver(alertMessage(), list, null, null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT); + + List messages = messages(); + assertThat(messages).hasSize(2); + assertThat(messages).extracting(m -> m.getValueString("toAddresses")).containsExactlyInAnyOrder("ops@example.com", "dev@example.com"); + assertThat(messages).extracting(m -> m.getValueString("idempotencyKey")).doesNotHaveDuplicates(); + } + + + + /******************************************************************************* + ** No enabled provider: qbit-email refuses, and the channel fails (not retried) + ** saying so. + *******************************************************************************/ + @Test + void testRefusedWithoutProvider() throws Exception + { + DeliveryResult result = new EmailChannel(config, ChannelType.EMAIL).deliver(alertMessage(), null, "ops@example.com", null); + assertThat(result.outcome()).isEqualTo(DeliveryResult.Outcome.FAILED); + assertThat(result.error()).startsWith("Email was refused"); + assertThat(messages()).isEmpty(); + } + + + + /******************************************************************************* + ** An unsubscribed address is suppressed: the channel says so, nothing is sent. + *******************************************************************************/ + @Test + void testSuppressedAddress() throws Exception + { + addProvider(); + runtime.asSystem(() -> AlertsDb.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "ops@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"))); + DeliveryResult result = new EmailChannel(config, ChannelType.EMAIL).deliver(alertMessage(), null, "ops@example.com", null); + assertThat(result.outcome()).isEqualTo(DeliveryResult.Outcome.FAILED); + assertThat(result.error()).contains("suppressed"); + } + + + + /******************************************************************************* + ** An immediate notification email: one message, alerts_notification, the + ** notification row linked, one recipient; running again sends nothing more. + ** SECURITY notices carry no body. + *******************************************************************************/ + @Test + void testImmediateNotification() throws Exception + { + addProvider(); + emailSettings("viewer-1", "JOB", "IMMEDIATE"); + emailSettings("viewer-2", "SECURITY", "IMMEDIATE"); + loginAs(ADMIN); + QAlerts.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"))); + QAlerts.notify(new NotificationSignal("New sign-in").withBody("Secret detail").withSeverity(NotificationSeverity.WARNING) + .withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.users("viewer-2"))); + + NotificationEmailer.Result result = new NotificationEmailer(runtime).run(); + assertThat(result.sent()).isEqualTo(2); + + List messages = messages(); + assertThat(messages).hasSize(2); + QRecord job = messages.stream().filter(m -> "viewer-1@example.com".equals(m.getValueString("toAddresses"))).findFirst().orElseThrow(); + assertThat(templateName(job)).isEqualTo("alerts_notification"); + assertThat(job.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(job.getValueString("subject")).isEqualTo("Export failed"); + assertThat(job.getValueString("idempotencyKey")).startsWith("alerts:notification:").doesNotContain("viewer-1@example.com"); + assertThat(job.getValueString("linkedTableName")).isEqualTo("alerts_notification"); + assertThat(job.getValueString("linkedRecordId")).isNotBlank(); + assertThat(job.getValueString("senderQbit")).isEqualTo("qbit-alerts"); + assertThat(job.getValueString("senderProcess")).isEqualTo("alertsDeliverNotifications"); + assertThat(job.getValueString("htmlBody")).contains("Disk full").contains("https://admin.example.com/app/widget").contains("My Notification Settings"); + QRecord security = messages.stream().filter(m -> "viewer-2@example.com".equals(m.getValueString("toAddresses"))).findFirst().orElseThrow(); + assertThat(security.getValueString("htmlBody")).doesNotContain("Secret detail"); + + assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(0); + assertThat(messages()).hasSize(2); + } + + + + /******************************************************************************* + ** A notification with no enabled provider fails (the row says FAILED). + *******************************************************************************/ + @Test + void testNotificationRefused() throws Exception + { + emailSettings("viewer-1", "JOB", "IMMEDIATE"); + loginAs(ADMIN); + QAlerts.notify(new NotificationSignal("Export failed").withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB) + .withAudience(NotificationAudience.users("viewer-1"))); + NotificationEmailer.Result refused = new NotificationEmailer(runtime).run(); + assertThat(refused.failed()).isEqualTo(1); + assertThat(refused.sent()).isEqualTo(0); + } + + + + /******************************************************************************* + ** A notification to an address that has been suppressed is skipped. + *******************************************************************************/ + @Test + void testNotificationSuppressed() throws Exception + { + addProvider(); + emailSettings("viewer-1", "JOB", "IMMEDIATE"); + loginAs(ADMIN); + QAlerts.notify(new NotificationSignal("Export failed").withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB) + .withAudience(NotificationAudience.users("viewer-1"))); + runtime.asSystem(() -> AlertsDb.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "viewer-1@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE"))); + NotificationEmailer.Result suppressed = new NotificationEmailer(runtime).run(); + assertThat(suppressed.skipped()).isEqualTo(1); + assertThat(suppressed.sent()).isEqualTo(0); + } + + + + /******************************************************************************* + ** The daily digest: one message to the user, alerts_digest, keyed by user and + ** window start, listing the unread items. + *******************************************************************************/ + @Test + void testDigest() throws Exception + { + addProvider(); + clock.set(Instant.parse("2026-10-12T12:05:00Z")); + emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST"); + loginAs(ADMIN); + QAlerts.notify(new NotificationSignal("Version 2.5 is out").withBody("Dark mode").withCategory(NotificationCategory.RELEASE) + .withAudience(NotificationAudience.user("viewer-1"))); + QAlerts.notify(new NotificationSignal("Version 2.6 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1"))); + + assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(1); + List messages = messages(); + assertThat(messages).hasSize(1); + QRecord message = messages.get(0); + assertThat(templateName(message)).isEqualTo("alerts_digest"); + assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION"); + assertThat(message.getValueString("toAddresses")).isEqualTo("viewer-1@example.com"); + assertThat(message.getValueString("subject")).isEqualTo("2 new notifications"); + assertThat(message.getValueString("idempotencyKey")).isEqualTo("alerts:digest:viewer-1:2026-10-12T12:00:00Z"); + assertThat(message.getValueString("htmlBody")).contains("Version 2.5 is out").contains("Dark mode").contains("Version 2.6 is out"); + assertThat(message.getValueString("linkedTableName")).isEqualTo("alerts_notification"); + assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0); + } + + + + /******************************************************************************* + ** Recipient hashes are stable, case-insensitive and hide the address. + *******************************************************************************/ + @Test + void testHash() + { + assertThat(AlertsEmailSender.hash("Ops@Example.com ")).isEqualTo(AlertsEmailSender.hash("ops@example.com")).hasSize(16).doesNotContain("ops"); + assertThat(AlertsEmailSender.hash("a@example.com")).isNotEqualTo(AlertsEmailSender.hash("b@example.com")); + } +} diff --git a/src/test/resources/email-h2.sql b/src/test/resources/email-h2.sql new file mode 100644 index 0000000..ae1ea2e --- /dev/null +++ b/src/test/resources/email-h2.sql @@ -0,0 +1,239 @@ +-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host. + +CREATE TABLE email_provider ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500), + label VARCHAR(500), + kind VARCHAR(500), + enabled BOOLEAN, + is_default BOOLEAN, + from_address VARCHAR(500), + from_name VARCHAR(500), + reply_to VARCHAR(500), + sending_domain VARCHAR(500), + host VARCHAR(500), + port INTEGER, + security VARCHAR(500), + region VARCHAR(500), + username_env_name VARCHAR(500), + secret_env_name VARCHAR(500), + webhook_secret_env_name VARCHAR(500), + sns_topic_arn VARCHAR(500), + configuration_set VARCHAR(500), + message_stream VARCHAR(500), + max_per_minute INTEGER, + max_per_day INTEGER, + track_opens BOOLEAN, + track_clicks BOOLEAN, + phi_allowed BOOLEAN, + health_status VARCHAR(500), + last_success_date TIMESTAMP, + last_failure_date TIMESTAMP, + last_failure_message VARCHAR(500), + dispatch_lock INTEGER, + default_slot INTEGER, + UNIQUE (name), + UNIQUE (default_slot) +); + +CREATE TABLE email_provider_sender_address ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + provider_id INTEGER NOT NULL, + address VARCHAR(500) NOT NULL, + display_name VARCHAR(500), + is_default BOOLEAN, + enabled BOOLEAN, + note TEXT, + UNIQUE (provider_id, address) +); + +CREATE TABLE email_message ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + status VARCHAR(500), + to_addresses TEXT, + cc_addresses TEXT, + bcc_addresses TEXT, + from_address VARCHAR(500), + from_address_id INTEGER, + reply_to VARCHAR(500), + subject TEXT, + template_id INTEGER, + template_version_id INTEGER, + brand_id INTEGER, + category VARCHAR(500), + locale VARCHAR(500), + variables_snapshot TEXT, + html_body TEXT, + text_body TEXT, + body_sha256 VARCHAR(500), + phi_approved BOOLEAN, + unsubscribe_url TEXT, + idempotency_key VARCHAR(500), + provider_id INTEGER, + provider_message_id VARCHAR(500), + attempt_count INTEGER, + next_attempt_date TIMESTAMP, + lease_expires_date TIMESTAMP, + last_error VARCHAR(500), + finished_date TIMESTAMP, + sent_date TIMESTAMP, + delivered_date TIMESTAMP, + bounced_date TIMESTAMP, + complained_date TIMESTAMP, + dead_letter_date TIMESTAMP, + linked_table_name VARCHAR(500), + linked_record_id VARCHAR(500), + sender_qbit VARCHAR(500), + sender_process VARCHAR(500), + suppressed_addresses TEXT, + send_after TIMESTAMP, + UNIQUE (idempotency_key) +); + +CREATE TABLE email_message_attempt ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + message_id INTEGER NOT NULL, + attempt_number INTEGER NOT NULL, + outcome VARCHAR(500), + provider_id INTEGER, + claimed_by VARCHAR(500), + start_date TIMESTAMP, + end_date TIMESTAMP, + lease_expires_date TIMESTAMP, + duration_millis INTEGER, + error VARCHAR(500), + UNIQUE (message_id, attempt_number) +); + +CREATE TABLE email_template ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500), + label VARCHAR(500), + category VARCHAR(500), + kind VARCHAR(500), + brand_id INTEGER, + active_version_id INTEGER, + approved_version_id INTEGER, + version_lock INTEGER, + phi_approved BOOLEAN, + phi_approved_by VARCHAR(500), + phi_approved_date TIMESTAMP, + edit_permission_name VARCHAR(500), + owner_qbit VARCHAR(500), + description TEXT, + UNIQUE (name) +); + +CREATE TABLE email_template_version ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + template_id INTEGER, + version_number INTEGER, + status VARCHAR(500), + locale VARCHAR(500), + subject TEXT, + preheader TEXT, + body_source TEXT, + text_source TEXT, + compiled_html TEXT, + variables_schema TEXT, + sample_variables TEXT, + created_by VARCHAR(500), + activated_date TIMESTAMP, + UNIQUE (template_id, version_number, locale) +); + +CREATE TABLE email_brand ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + name VARCHAR(500), + label VARCHAR(500), + logo_url VARCHAR(500), + logo_dark_url VARCHAR(500), + logo_alt VARCHAR(500), + primary_color VARCHAR(500), + text_color VARCHAR(500), + background_color VARCHAR(500), + dark_background_color VARCHAR(500), + dark_text_color VARCHAR(500), + dark_primary_color VARCHAR(500), + dark_button_text_color VARCHAR(500), + button_text_color VARCHAR(500), + footer_markdown TEXT, + postal_address TEXT, + social_links TEXT, + site_url VARCHAR(500), + is_default BOOLEAN, + app_name VARCHAR(500), + UNIQUE (name) +); + +CREATE TABLE email_suppression ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + address VARCHAR(500), + scope VARCHAR(500), + reason VARCHAR(500), + source_message_id INTEGER, + source_event_id INTEGER, + note TEXT, + created_date TIMESTAMP, + expires_date TIMESTAMP, + UNIQUE (address, scope) +); + +CREATE INDEX email_message_due ON email_message (status, next_attempt_date); +CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date); +CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id); + +CREATE TABLE email_webhook_inbox ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + provider_id INTEGER NOT NULL, + kind VARCHAR(500) NOT NULL, + external_event_id VARCHAR(500) NOT NULL, + nonce VARCHAR(500), + status VARCHAR(500) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_date TIMESTAMP, + last_error VARCHAR(500), + payload_json TEXT, + payload_sha256 VARCHAR(500), + received_date TIMESTAMP, + processed_date TIMESTAMP, + UNIQUE (provider_id, external_event_id), + UNIQUE (provider_id, nonce) +); + +CREATE TABLE email_event ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + create_date TIMESTAMP, + modify_date TIMESTAMP, + message_id INTEGER NOT NULL, + provider_id INTEGER, + event_type VARCHAR(500) NOT NULL, + bounce_type VARCHAR(500), + recipient VARCHAR(500), + occurred_date TIMESTAMP, + detail VARCHAR(500), + url TEXT, + event_key VARCHAR(500) NOT NULL UNIQUE +); +CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date); +CREATE INDEX email_event_message ON email_event (message_id); +CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date); +CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);