feat(email): send alert, notification and digest email through qbit-email when installed #23

Merged
KofTwentyTwo merged 3 commits from feature/email-qbit into develop 2026-10-14 13:27:25 +00:00
9 changed files with 1318 additions and 30 deletions

No files matched your search

+1
View File
@@ -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.
+13 -1
View File
@@ -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:<alert ids>:<recipient hash>:<message kind>:<minute>` (the kind and minute keep a reminder or a reopened alert from looking like a repeat), `alerts:notification:<notification id>:<recipient hash>` and `alerts:digest:<user>:<digest window start>`.
- **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.
+16
View File
@@ -22,8 +22,15 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<!-- Optional email integration: only AlertsEmailSender references its API, -->
<!-- after classpath and EmailRuntime.config(instance) checks. EmailRuntime -->
<!-- lives in the root package; the guardrail accepts package prefixes. -->
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
</properties>
<parent>
@@ -82,6 +89,15 @@
<optional>true</optional>
</dependency>
<!-- Email through qbit-email when it is produced in the same instance; -->
<!-- otherwise core SendMessageAction and messagingProviderName. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
@@ -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<String> validate(QRecord channel, AlertSecretResolver secrets)
{
List<String> 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<String> addresses)
{
List<Map<String, Serializable>> items = new ArrayList<>();
for(AlertMessage.Entry entry : message.entries())
{
Map<String, Serializable> 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<String, Serializable> item, String name, Serializable value)
{
if(value != null)
{
item.put(name, value);
}
}
/*******************************************************************************
** The addresses in an email list channel's settings.
*******************************************************************************/
@@ -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));
}
}
@@ -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<Map<String, Serializable>> items, String link, String settingsUrl,
List<Integer> alertIds, Instant now) throws QException
{
ensure(TEMPLATE_ALERT);
List<Integer> 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<String, Serializable> 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<String, Serializable> 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<Map<String, Serializable>> 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));
});
}
}
@@ -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<QRecord> items = digest.getValue().stream().map(pair -> pair[1]).toList();
boolean ok = send(address(setting), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items);
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<QRecord> 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<Map<String, Serializable>> 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<String, Serializable> item(QRecord notification)
{
Map<String, Serializable> 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<QRecord> items)
{
@@ -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<QRecord> 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", "<p>html</p>", "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<QRecord> 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<QRecord> 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<QRecord> 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<QRecord> 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"));
}
}
+239
View File
@@ -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);