Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08e729728
|
||
|
|
ee002b2cb3 | ||
|
|
5515c4c2bc
|
||
|
|
ae1f279633
|
||
|
|
8431476609 | ||
|
|
3d56ccc403
|
||
|
|
9e139fe039 | ||
|
|
8004089b47
|
No files matched your search
@@ -44,7 +44,7 @@ Packages:
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.1.0` (qqq 4.0.0), with architecture guardrails enforced and strict UI API route tests enabled. The default-active `qqq-snapshot` profile imports released `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Build normally without a version override. Remove that activation when the parent moves to 4.1.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
+6
-2
@@ -5,10 +5,14 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Changed
|
||||
- Target QQQ `4.1.0-RC.6` for the UI extension API; local verification uses a command-line version override only.
|
||||
- Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails.
|
||||
- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): 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.
|
||||
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
|
||||
- Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL.
|
||||
- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration. Follower fan-out waits for core #1164 (#16, F12).
|
||||
- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration (#16, F12).
|
||||
- Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7).
|
||||
- Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes.
|
||||
- Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
|
||||
|
||||
@@ -56,8 +56,11 @@ hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching`
|
||||
|
||||
Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old
|
||||
RECORD subscriptions remain readable without migration. The panel creates no subscriptions;
|
||||
Next uses its collaboration module when `follow.available` is true. Follower fan-out waits
|
||||
for core QRun-IO/qqq#1164.
|
||||
Next uses its collaboration module when `follow.available` is true. Alerts with active record
|
||||
refs also reach followers supplied by core's instance-level followers provider (`FollowEvent.ALERT`),
|
||||
when FOLLOW is enabled and the follower can read the source record. Followers and subscribers
|
||||
share recipient deduplication, channel preferences and suppression. No collaboration qbit dependency
|
||||
is required; without a provider, subscription behavior is unchanged. See [follower routing](docs/UI-API.md#record-follower-routing).
|
||||
|
||||
The old Alertmanager path is available for one minor release only via
|
||||
`withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode,
|
||||
@@ -67,12 +70,17 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (targets `4.1.0-RC.6`; release pending).
|
||||
- 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
|
||||
tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot
|
||||
override. The default-active `qqq-snapshot` profile retains its historical name but selects
|
||||
the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM.
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
@@ -147,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 the published `com.kingsrook.qbits.email.api.EmailApi` in qbit-email `0.1.0-RC.2` instead of core `SendMessageAction`. Template registration, sends and installation detection use only the public API; templates, categories, idempotency keys, linked records and outcome mapping are unchanged:
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -161,7 +161,7 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`;
|
||||
signals use the existing bounded queue and `NotificationService` for audience resolution,
|
||||
inbox recipients, scheduling and category/email preferences. Alert subscription channels
|
||||
and their quiet-hour suppression keep their existing behavior.
|
||||
keep their existing behavior. Quiet-hour scheduling for alerts remains unimplemented (see README).
|
||||
- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed.
|
||||
Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and
|
||||
rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter
|
||||
@@ -265,3 +265,5 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md),
|
||||
with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following
|
||||
belongs to collaboration; the old alerts panel watch route is removed.
|
||||
Record-linked alerts fan out through core's RC.6 followers provider with source-record access
|
||||
checks and the ordinary alert preference/suppression pipeline; see [record follower routing](UI-API.md#record-follower-routing).
|
||||
+24
-3
@@ -58,9 +58,26 @@ and scope subscriptions; existing record-scoped watch rows are deprecated and re
|
||||
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
|
||||
not be used as collaboration follow state.
|
||||
|
||||
There is no follower fan-out yet. `AlertRouter.destinations` contains
|
||||
`STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)` at the point where follower destinations
|
||||
will join the existing recipient visibility and suppression pipeline.
|
||||
## Record follower routing
|
||||
|
||||
For every active record ref on an alert, `AlertRouter.destinations` calls RC.6's
|
||||
`RecordFollowers.get(instance, ref, FollowEvent.ALERT)`. Register a core
|
||||
`RecordFollowersProviderInterface` through instance `RecordFeaturesMetaData.withFollowersProvider`
|
||||
and enable `RecordFeature.FOLLOW` for the source table (or by instance default). The provider
|
||||
selects followers for ALERT; core recommends treating ALERT like CHANGE unless the provider
|
||||
has a finer preference. The alerts qbit does not depend on qbit-collaboration.
|
||||
|
||||
Each follower must have a known automated recipient session and be able to query the source
|
||||
record through its table permissions and record security locks. Missing records and unknown
|
||||
or denied access do not add follower destinations. The query uses the source table's configured
|
||||
primary key. Eligible followers join the existing destination map, deduplicated by user and
|
||||
channel across subscriptions and record refs. Existing subscription destinations take precedence.
|
||||
|
||||
Followers use ordinary in-app/email settings, vacation pause for external delivery, alert
|
||||
visibility checks, type notification switches, mutes, flapping, channel limits and duplicate
|
||||
suppression. Quiet-hour scheduling remains unimplemented for all alert recipients, as listed
|
||||
in the README; follower routing does not introduce a separate delivery policy. With no provider,
|
||||
FOLLOW disabled, or a provider failure, existing subscription routing is unchanged.
|
||||
|
||||
## Alertmanager machine webhook
|
||||
|
||||
@@ -70,6 +87,10 @@ JSON request and `{raised, resolved, ignored}` response. No QQQ user session is
|
||||
|
||||
The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias:
|
||||
|
||||
The architecture guardrail's static G3 scan reports this intentional raw UI-prefix route
|
||||
as an advisory. Runtime strict-mode coverage rejects the alias; only the migration test
|
||||
starts a standalone Javalin instance with it enabled. Normal application tests remain strict.
|
||||
|
||||
```java
|
||||
new AlertsQBitConfig()
|
||||
.withLegacyAlertmanagerPath(true)
|
||||
|
||||
@@ -22,12 +22,16 @@
|
||||
<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.2</qbit-email.version>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -80,6 +84,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>
|
||||
|
||||
@@ -334,7 +334,7 @@ public class DeliveryWorker
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stand-in alert a TEST message describes.
|
||||
** The sample alert a TEST message describes.
|
||||
*******************************************************************************/
|
||||
private static QRecord testAlert(Instant now)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -391,7 +391,7 @@ public final class ExternalChannels
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The message's entries (a TEST message gets one stand-in entry).
|
||||
** The message's entries (a TEST message gets one sample entry).
|
||||
*******************************************************************************/
|
||||
static List<AlertMessage.Entry> entries(AlertMessage message)
|
||||
{
|
||||
|
||||
@@ -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_API_CLASS = "com.kingsrook.qbits.email.api.EmailApi";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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_API_CLASS);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (AlertsEmailSender.isInstalled(qInstance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qbits.email.api.EmailApiResult;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
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 EmailApi}, 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 (EmailApi.isInstalled(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
|
||||
EmailApiRequest 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(EmailApi.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);
|
||||
EmailApiRequest 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(EmailApi.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);
|
||||
EmailApiRequest 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(EmailApi.send(request)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The parts every message has: one recipient, the sender and the key.
|
||||
*******************************************************************************/
|
||||
private static EmailApiRequest base(String to, String process, String idempotencyKey)
|
||||
{
|
||||
return (new EmailApiRequest().withTo(to).withCategory(EmailApiCategory.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(EmailApiRequest 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(EmailApiResult 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
|
||||
{
|
||||
EmailApi.ensureTemplate(switch(name)
|
||||
{
|
||||
case TEMPLATE_ALERT -> new EmailApiTemplate()
|
||||
.withName(TEMPLATE_ALERT).withLabel("Alerts: alert").withCategory(EmailApiCategory.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 EmailApiTemplate()
|
||||
.withName(TEMPLATE_NOTIFICATION).withLabel("Alerts: notification").withCategory(EmailApiCategory.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 EmailApiTemplate()
|
||||
.withName(TEMPLATE_DIGEST).withLabel("Alerts: daily digest").withCategory(EmailApiCategory.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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,21 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
|
||||
@@ -303,7 +310,7 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** Every destination for an alert: subscribed users (directly or through a
|
||||
** team) with their in-app and email channels (or the subscription's
|
||||
** chosen channels), and subscribed channels.
|
||||
** chosen channels), subscribed channels, and readable record followers.
|
||||
*******************************************************************************/
|
||||
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException
|
||||
{
|
||||
@@ -353,8 +360,24 @@ public class AlertRouter
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7): merge record followers
|
||||
// from RecordFollowersProviderInterface here, before the existing visibility/suppression checks.
|
||||
for(String recordKey : target.recordKeys())
|
||||
{
|
||||
String[] parts = recordKey.split(":", 2);
|
||||
RecordReference reference = new RecordReference(parts[0], parts[1]);
|
||||
for(RecordFollower follower : RecordFollowers.get(runtime.getQInstance(), reference, FollowEvent.ALERT))
|
||||
{
|
||||
String userId = follower == null ? null : follower.getUserId();
|
||||
if(!StringUtils.hasContent(userId) || !canReadRecord(userId, reference))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(null, preference))
|
||||
{
|
||||
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(destinations.values()));
|
||||
}
|
||||
|
||||
@@ -367,7 +390,7 @@ public class AlertRouter
|
||||
private List<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
|
||||
{
|
||||
List<QRecord> channels = new ArrayList<>();
|
||||
String overrides = subscription.getValueString("channelOverrideIds");
|
||||
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
|
||||
if(StringUtils.hasContent(overrides))
|
||||
{
|
||||
for(String id : overrides.split("[,\\s]+"))
|
||||
@@ -518,6 +541,39 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers must be able to read the source record, including its record locks.
|
||||
** Reuse recipient sessions; unknown access fails closed before adding channels.
|
||||
*******************************************************************************/
|
||||
private boolean canReadRecord(String userId, RecordReference reference)
|
||||
{
|
||||
try
|
||||
{
|
||||
QSession session = automatedSession(userId);
|
||||
if(session == null || !CollectionUtils.nullSafeHasContents(session.getPermissions()))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QInstance instance = runtime.getQInstance();
|
||||
AlertsSecurity.applyToSession(instance, session);
|
||||
boolean[] readable = new boolean[1];
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, session), () ->
|
||||
{
|
||||
String primaryKey = instance.getTable(reference.tableName()).getPrimaryKeyField();
|
||||
readable[0] = PermissionsHelper.hasTablePermission(new AbstractActionInput(), reference.tableName(), TablePermissionSubType.READ)
|
||||
&& AlertsDb.first(reference.tableName(), AlertsDb.eq(primaryKey, reference.primaryKey())) != null;
|
||||
});
|
||||
return (readable[0]);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not verify follower source-record access", e, logPair("userId", userId));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session for a user, from the instance's authentication module (null
|
||||
** when it can't make one).
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -237,9 +237,9 @@ class ExternalChannelsTest
|
||||
List<?> resolvedBody = JsonUtils.toObject(received.get(1).body(), List.class);
|
||||
assertThat(((Map<?, ?>) resolvedBody.get(0)).get("endsAt")).isEqualTo("2026-10-12T11:00:00Z");
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// a test message is one stand-in alert, already over //
|
||||
//////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////
|
||||
// a test message is one sample alert, already over //
|
||||
////////////////////////////////////////////////////
|
||||
alertmanager.deliver(new AlertMessage("TEST: hello", "t", "h", "INFO", "TEST", List.of(), "https://x/app/alerts_alert"), channel, null, secrets);
|
||||
assertThat(received.get(2).body()).contains("alerts.test");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.ArrayList;
|
||||
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.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiMessage;
|
||||
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). G1 permits the
|
||||
** producer and configuration imports in test setup.
|
||||
*******************************************************************************/
|
||||
@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("email_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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find messages through the API, oldest first. Read the fixture rows with
|
||||
** core QQQ for content assertions: the API deliberately omits bodies,
|
||||
** recipients and sender details from its metadata projection.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> messages() throws QException
|
||||
{
|
||||
List<QRecord> messages = new ArrayList<>();
|
||||
for(EmailApiMessage message : EmailApi.findMessagesByIdempotencyKeyPrefix("alerts:", 100))
|
||||
{
|
||||
messages.add(runtime.asSystem(() -> AlertsDb.byId("email_message", message.id())));
|
||||
}
|
||||
return (messages);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name of a message's template.
|
||||
*******************************************************************************/
|
||||
private String templateName(QRecord message) throws QException
|
||||
{
|
||||
return (EmailApi.getMessage(message.getValueInteger("id")).orElseThrow().templateName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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(EmailApi.isInstalled(qInstance)).isTrue();
|
||||
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);
|
||||
DeliveryResult delivered = channel.deliver(alertMessage(), null, "ops@example.com", null);
|
||||
assertThat(delivered.outcome()).as("Delivery outcome: %s", delivered.error()).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("email_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("email_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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core followers enter the normal routing pipeline without a collaboration qbit.
|
||||
*******************************************************************************/
|
||||
class FollowerFanoutTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep provider state isolated between tests; absence is the default.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void resetProvider()
|
||||
{
|
||||
Provider.followers = List.of();
|
||||
Provider.references.clear();
|
||||
Provider.failure = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the optional core SPI in instance metadata, with FOLLOW enabled.
|
||||
*******************************************************************************/
|
||||
private void followers(String... userIds)
|
||||
{
|
||||
Provider.followers = java.util.Arrays.stream(userIds).map(id -> new RecordFollower().withUserId(id).withNotifyOnChange(true)).toList();
|
||||
RecordFeaturesMetaData.ofOrWithNew(qInstance).withEnabled(RecordFeature.FOLLOW, true)
|
||||
.withFollowersProvider(new QCodeReference(Provider.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normal subscriber must retain their destination when also following.
|
||||
*******************************************************************************/
|
||||
private void subscribe(String userId) throws QException
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", "USER").withValue("userId", userId)
|
||||
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a real source row, without a subject-table visibility lock on the alert.
|
||||
*******************************************************************************/
|
||||
private void raise(Integer... widgetIds) throws QException
|
||||
{
|
||||
AlertSignal signal = new AlertSignal("test.followers").withTitle("Widget needs attention").withSeverity(AlertSeverity.ERROR)
|
||||
.withDedupKey(java.util.Arrays.toString(widgetIds));
|
||||
for(Integer widgetId : widgetIds)
|
||||
{
|
||||
signal.withRecordRef(AlertRecordRef.of(WIDGET, widgetId));
|
||||
}
|
||||
runtime.getEngine().raise(signal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers from multiple refs and an existing subscription receive only once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void followersJoinSubscribersOnce() throws Exception
|
||||
{
|
||||
List<Integer> widgets = insertWidgets("BROKEN", "BROKEN");
|
||||
followers(VIEWER, "viewer-2", VIEWER);
|
||||
subscribe(VIEWER);
|
||||
raise(widgets.toArray(Integer[]::new));
|
||||
|
||||
assertThat(Provider.references).containsExactly(new RecordReference(WIDGET, widgets.get(0).toString()), new RecordReference(WIDGET, widgets.get(1).toString()));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactlyInAnyOrder(VIEWER, "viewer-2");
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("SENT"));
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", VIEWER))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValue("subscriptionId")).isNotNull());
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table access and a known recipient session are required, even for the inbox.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inaccessibleAndUnknownFollowersAreExcluded() throws Exception
|
||||
{
|
||||
followers(VIEWER, OUTSIDER, "unknown-user", "");
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table permission alone is insufficient when the source has record locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sourceRecordLocksAreEnforced() throws Exception
|
||||
{
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
runtime.asSystem(() -> AlertsDb.update(WIDGET, new QRecord().withValue("id", widget).withValue("name", VIEWER)));
|
||||
qInstance.getTable(WIDGET).withRecordSecurityLock(AlertsSecurity.ownerLock("name"));
|
||||
followers(VIEWER, "viewer-2");
|
||||
raise(widget);
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No provider preserves subscription behavior, even with a source record.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noProviderPreservesSubscribers() throws Exception
|
||||
{
|
||||
subscribe(VIEWER);
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recordless alerts never ask for followers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noRecordDoesNotFanOut() throws Exception
|
||||
{
|
||||
followers(VIEWER);
|
||||
raise();
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A disabled core FOLLOW feature leaves existing recipients intact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void disabledFollowPreservesSubscribers() throws Exception
|
||||
{
|
||||
followers("viewer-2");
|
||||
subscribe(VIEWER);
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
RecordFeaturesMetaData.ofOrWithNew(qInstance.getTable(WIDGET)).withEnabled(RecordFeature.FOLLOW, false);
|
||||
raise(widget);
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core isolates provider failures so normal subscribers still receive the alert.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerFailurePreservesSubscribers() throws Exception
|
||||
{
|
||||
followers("viewer-2");
|
||||
subscribe(VIEWER);
|
||||
Provider.failure = true;
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(Provider.references).hasSize(1);
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers use ordinary in-app/email preferences and vacation suppression.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void followerPreferencesAndPauseApply() throws Exception
|
||||
{
|
||||
followers(VIEWER, "viewer-2", "viewer-3");
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("inAppEnabled", false).withValue("emailEnabled", false));
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-2").withValue("inAppEnabled", true)
|
||||
.withValue("emailEnabled", true).withValue("emailAddress", "follower@example.com").withValue("pausedUntil", clock.instant().plus(Duration.ofDays(1))));
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-3").withValue("inAppEnabled", false)
|
||||
.withValue("emailEnabled", true).withValue("emailAddress", "other@example.com"));
|
||||
return (null);
|
||||
});
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly("viewer-2");
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(3);
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("suppressedReason", "USER_PAUSED"))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-2"));
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", "viewer-3"))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("PENDING"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A core-only provider; the event preference decision belongs to the provider.
|
||||
*******************************************************************************/
|
||||
public static class Provider implements RecordFollowersProviderInterface
|
||||
{
|
||||
static List<RecordFollower> followers = List.of();
|
||||
static final List<RecordReference> references = new ArrayList<>();
|
||||
static boolean failure;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Assert ALERT dispatch and record lookup context at the SPI boundary.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
|
||||
{
|
||||
assertThat(instance).isSameAs(QContext.getQInstance());
|
||||
assertThat(event).isEqualTo(FollowEvent.ALERT);
|
||||
references.add(reference);
|
||||
if(failure)
|
||||
{
|
||||
throw (new QException("Test provider unavailable"));
|
||||
}
|
||||
return (followers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
-- 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),
|
||||
rfc_message_id 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),
|
||||
UNIQUE (rfc_message_id)
|
||||
);
|
||||
|
||||
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);
|
||||
Reference in new issue
Block a user