Every Email table used, seeded or explained; setup card and help #19
No files matched your search
@@ -8,6 +8,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
### Changed
|
||||
- Built on QQQ 4.1.0-RC.7 (default QQQ profile).
|
||||
|
||||
### Added
|
||||
- Starter defaults changelog `db/liquibase/qbit-email/email-defaults.changelog.sql` (insert-only, guarded): one non-default, high-contrast `neutral` brand, only when no brand exists. Tested through real Liquibase on H2, including that it never changes rendering.
|
||||
- Help for the Email app and every `email_*` table (`help/qbit-email.yaml`, STAND-IN core#1198): what the table is for and how its rows get created, shown when it is empty.
|
||||
- A Setup card first on the Email home (email admins), using the sender's own provider and brand resolution and `EmailApi.readiness`. Opening the home also creates the built-in secure-message template.
|
||||
- `EmailTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
|
||||
|
||||
### Fixed
|
||||
- `email_template.ownerQbit` was stored but never read, and the built-in template did not set it. The built-in now names `qbit-email`, the field is read-only with help, and messages sent from a template name its owner qbit as `senderQbit` unless the request names one.
|
||||
|
||||
### Added
|
||||
- `EmailJobEffectTarget` automatically registers the durable-jobs `email` target, with idempotent outbox handoff, refusal reasons, reconciliation and job-owner session enforcement; H2 integration coverage and payload/registration documentation.
|
||||
- `EmailApiReadiness.failedChecks()` and `failed(check)` with `EmailApiReadinessCheck` (NOT_INSTALLED, PROVIDER_MISSING, PROVIDER_INVALID, PHI_PROVIDER, PUBLIC_BASE_URL, UNSUBSCRIBE_KEY, BRAND_POSTAL_ADDRESS, BODY_KEY), so callers react to a specific prerequisite without parsing reason text.
|
||||
|
||||
@@ -39,6 +39,22 @@ The scheduler must already exist. Without a scheduler, callers can enqueue and r
|
||||
Configuration is looked up from the current `QInstance` on every call, never cached
|
||||
in a static variable.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
Include order: the DDL first, then the starter defaults from the host's master changelog (the qbit itself never runs DDL):
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
They are insert-only, hold no secrets, skip a host without the tables, and never change how mail looks on deploy. They add one brand, `neutral` (white background, near-black text, blue buttons, light and dark schemes, every pair at least 4.5:1 contrast), and only when the host has no brand at all. It is not the default brand: copy it for your own, or tick Default Brand on it.
|
||||
|
||||
Everything else starts empty on purpose, and each table's help says why: a **provider** is your own connection (a server and the NAMES of its secret variables), so nothing can be sent until you add one; messages, attempts, events, the webhook inbox and suppressions are runtime data; the secure-message template is created by the qbit on first use and when the Email home opens, and other qbits register theirs with `EmailApi.ensureTemplate`.
|
||||
|
||||
Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-email.yaml`). Email admins also get a **Setup** card first on the home: provider, sender addresses, brands, templates, marketing readiness, delivery tracking, suppression list and scheduler, each with its state and the next step. It uses the same provider and brand resolution as the sender and `EmailApi.readiness`.
|
||||
|
||||
A template's **Owner Qbit** (the qbit that registered it) is now used: messages sent from the template name that qbit as their sender unless the request names one.
|
||||
|
||||
## Tables and permissions
|
||||
|
||||
| Table | Navigation | Writes |
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased: starter defaults, setup card, table help
|
||||
|
||||
1. **Add the defaults changelog** after the changesets that create the `email_*` tables (`docs/ddl/email-postgres.sql`) in the host's master changelog:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It is insert-only and guarded: a host that already has any brand gets nothing, a host without the table skips it. The `neutral` brand it adds is not the default brand, so existing mail renders exactly as before.
|
||||
2. **Behavior change.** Messages sent from a template whose Owner Qbit is set, with no sender named in the request, now record that qbit as `senderQbit` (it was empty). Nothing else about sending changes.
|
||||
3. The help bundle and the Setup card need no configuration. Existing built-in templates created before this release keep an empty Owner Qbit.
|
||||
@@ -134,6 +134,13 @@
|
||||
<version>${h2.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- EmailDefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2. -->
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.33.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.email.delivery.EmailAlerts;
|
||||
import com.kingsrook.qbits.email.metadata.EmailHelp;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
@@ -196,6 +197,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
output.add(EmailApp.app(qBitConfig, tables));
|
||||
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
output.addSelfToInstance(qInstance);
|
||||
EmailHelp.apply(qInstance, qBitConfig.getAppName());
|
||||
registerWebhooks(qInstance);
|
||||
com.kingsrook.qbits.email.outbox.EmailJobEffectTarget.register(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.template.EmailBuiltIns;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -101,7 +102,13 @@ public final class EmailHome
|
||||
provider.setValue("headroom", Math.max(0, limit - used));
|
||||
}
|
||||
}
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions())));
|
||||
List<EmailSetupStatus.Item> setup = List.of();
|
||||
if(admin)
|
||||
{
|
||||
EmailBuiltIns.ensure();
|
||||
setup = EmailSetupStatus.load();
|
||||
}
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions(), setup)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -61,8 +61,19 @@ public final class EmailHomeView
|
||||
** One ordered home with real, viewer-scoped data supplied by EmailHome.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions)
|
||||
{
|
||||
return (render(totals, hourly, providers, admin, messages, suppressions, List.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same home with the Setup card first (email admins), when there are items.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions, List<EmailSetupStatus.Item> setup)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
|
||||
setup(html, setup);
|
||||
html.append("<div style=\"" + WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr));gap:12px\">");
|
||||
List<String> labels = List.of("Sent (24 h)", "Delivered (24 h)", "Dead letters (24 h)", "Bounced (7 d)", "Complaints (7 d)", "Suppressed addresses");
|
||||
for(int i = 0; i < labels.size(); i++)
|
||||
@@ -82,6 +93,46 @@ public final class EmailHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each part of Email, set up or not, and the next step, linked
|
||||
** to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, List<EmailSetupStatus.Item> items)
|
||||
{
|
||||
if(items.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
section(html, "Setup");
|
||||
long todo = items.stream().filter(item -> item.state() == EmailSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 8px;opacity:.75\">").append(todo == 0 ? "Everything Email needs is set up." : todo + (todo == 1 ? " item needs" : " items need") + " attention.").append("</p>");
|
||||
for(EmailSetupStatus.Item item : items)
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> OK;
|
||||
case TODO -> FAIL;
|
||||
case DEFAULT -> "#d97706";
|
||||
case OPTIONAL -> "#808080";
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
String name = item.tableName() == null ? esc(item.label()) : "<a style=\"color:inherit\" href=\"/app/" + esc(item.tableName()) + "\">" + esc(item.label()) + "</a>";
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Part", "<strong>" + name + "</strong><br><span style=\"color:" + color + "\">" + label + "</span>"))
|
||||
.append(cell("Status", esc(item.detail())))
|
||||
.append(cell("Next step", esc(item.nextStep() == null ? "Nothing to do" : item.nextStep()))).append("</div>");
|
||||
}
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded stacked bar chart, including exact values in each bar's title.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.email.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadiness;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Email home: each configuration table, whether it is
|
||||
** set up, and the next step. It uses the same resolution the sender uses (the
|
||||
** default provider, the brand lookup, EmailApi.readiness), so it never reports
|
||||
** a problem the sender would not have.
|
||||
*******************************************************************************/
|
||||
public final class EmailSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
|
||||
** OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items (as the qbit, for an email admin).
|
||||
*******************************************************************************/
|
||||
public static List<Item> load() throws QException
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// the provider: the same check a send makes (readiness) //
|
||||
///////////////////////////////////////////////////////////
|
||||
EmailApiReadiness readiness = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL);
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter());
|
||||
QRecord active = providers.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("enabled")) && Boolean.TRUE.equals(p.getValueBoolean("isDefault")))
|
||||
.findFirst().orElse(null);
|
||||
if(readiness.failed(EmailApiReadinessCheck.PROVIDER_MISSING))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, providers.isEmpty() ? "No provider yet, so nothing can be sent." : "No enabled default provider, so nothing can be sent.",
|
||||
"Add one with New (kind, host or region, from address, and the NAMES of the secret variables), turn on Default, then Send Test Email."));
|
||||
}
|
||||
else if(readiness.failed(EmailApiReadinessCheck.PROVIDER_INVALID))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider's configuration is invalid.", "Open it and fix the highlighted settings, then Send Test Email."));
|
||||
}
|
||||
else if(active != null && "UNHEALTHY".equals(active.getValueString("healthStatus")))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing: " + valueOr(active.getValueString("lastFailureMessage"), "see its last failure") + ".",
|
||||
"Check its settings and secret variables, then Send Test Email."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.OK, valueOr(active == null ? null : active.getValueString("label"), "The default provider") + " sends mail.", null));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// sender addresses: the provider's own from address works //
|
||||
///////////////////////////////////////////////////////////////
|
||||
int senders = SystemWrites.count(EmailTables.SENDER, new QQueryFilter());
|
||||
items.add(senders == 0
|
||||
? new Item("Sender addresses", EmailTables.SENDER, State.OPTIONAL, "None: the provider's own from address is used.", "Add some if one provider sends as several mailboxes.")
|
||||
: new Item("Sender addresses", EmailTables.SENDER, State.OK, senders + (senders == 1 ? " address." : " addresses."), null));
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// brand: the lookup the renderer does (app brand, then default) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
List<QRecord> brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("appName", config.getAppName()));
|
||||
if(brands.isEmpty())
|
||||
{
|
||||
brands = SystemWrites.query(EmailTables.BRAND, EmailStore.equal("isDefault", true));
|
||||
}
|
||||
if(brands.size() > 1)
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.TODO, "More than one default brand matches, so branded sends are refused.", "Leave Default Brand on only one."));
|
||||
}
|
||||
else if(brands.isEmpty())
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.DEFAULT, "No default brand: emails use the plain neutral look.", "Copy the neutral brand, set your logo, colors and footer, and tick Default Brand."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Brands", EmailTables.BRAND, State.OK, valueOr(brands.getFirst().getValueString("label"), "The default brand") + " is the default.", null));
|
||||
}
|
||||
|
||||
///////////////
|
||||
// templates //
|
||||
///////////////
|
||||
int templates = SystemWrites.count(EmailTables.TEMPLATE, new QQueryFilter());
|
||||
items.add(templates == 0
|
||||
? new Item("Templates", EmailTables.TEMPLATE, State.DEFAULT, "No template yet: plain messages can still be sent.", "Other qbits register theirs; add your own with New, then Activate Version.")
|
||||
: new Item("Templates", EmailTables.TEMPLATE, State.OK, templates + (templates == 1 ? " template." : " templates."), null));
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// marketing: the same checks as a MARKETING send //
|
||||
/////////////////////////////////////////////////////
|
||||
EmailApiReadiness marketing = EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.MARKETING);
|
||||
List<String> missing = new ArrayList<>();
|
||||
if(marketing.failed(EmailApiReadinessCheck.PUBLIC_BASE_URL))
|
||||
{
|
||||
missing.add("a public base URL");
|
||||
}
|
||||
if(marketing.failed(EmailApiReadinessCheck.UNSUBSCRIBE_KEY))
|
||||
{
|
||||
missing.add("the QQQ_EMAIL_UNSUBSCRIBE_KEY variable");
|
||||
}
|
||||
if(marketing.failed(EmailApiReadinessCheck.BRAND_POSTAL_ADDRESS))
|
||||
{
|
||||
missing.add("a default brand with a postal address");
|
||||
}
|
||||
items.add(missing.isEmpty()
|
||||
? new Item("Marketing email", EmailTables.BRAND, State.OK, "Ready: unsubscribe links, base URL and postal address are set.", null)
|
||||
: new Item("Marketing email", EmailTables.BRAND, State.OPTIONAL, "Off until set up: needs " + String.join(", ", missing) + ".", "Only needed if you send marketing email."));
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// delivery tracking: the provider's webhook secret name //
|
||||
//////////////////////////////////////////////////////////
|
||||
boolean webhook = active != null && valueOr(active.getValueString("webhookSecretEnvName"), "").length() > 0;
|
||||
int inbox = SystemWrites.count(EmailTables.INBOX, new QQueryFilter());
|
||||
items.add(webhook
|
||||
? new Item("Delivery tracking", EmailTables.INBOX, State.OK, inbox == 0 ? "A webhook secret is set; no callback has arrived yet." : inbox + " provider callbacks received.", null)
|
||||
: new Item("Delivery tracking", EmailTables.INBOX, State.OPTIONAL, "Off: the default provider has no webhook secret variable, so bounces and complaints are not tracked.",
|
||||
"Set the provider's webhook secret variable and point the provider at " + config.getWebhookBasePath() + "."));
|
||||
|
||||
int suppressed = SystemWrites.count(EmailTables.SUPPRESSION, new QQueryFilter());
|
||||
items.add(new Item("Suppression list", EmailTables.SUPPRESSION, State.OPTIONAL, suppressed == 0 ? "No blocked addresses yet; bounces, complaints and unsubscribes add them." : suppressed + " blocked addresses.", null));
|
||||
|
||||
items.add(config.getSchedulerName() == null
|
||||
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so the outbox is only sent when someone runs Dispatch by hand.", "Set schedulerName in the Email config.")
|
||||
: new Item("Scheduler", null, State.OK, "Dispatch, webhooks, maintenance and retention run on the " + config.getSchedulerName() + " scheduler.", null));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value, or the fallback when blank.
|
||||
*******************************************************************************/
|
||||
private static String valueOr(String value, String fallback)
|
||||
{
|
||||
return (value == null || value.isBlank() ? fallback : value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.email.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-email.yaml (the QQQ help-bundle format) to the
|
||||
** Email app and its email_* tables: what each table is for and how
|
||||
** its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class EmailHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-email.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = EmailHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-email".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every email_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, String appName) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(appName);
|
||||
JsonNode appEntry = entries.get("app:email;slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -344,7 +344,8 @@ public final class EmailTables
|
||||
.withField(field("phiApprovedBy", QFieldType.STRING).withMaxLength(500).withIsEditable(false))
|
||||
.withField(field("phiApprovedDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("editPermissionName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("ownerQbit", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("ownerQbit", QFieldType.STRING).withMaxLength(500).withIsEditable(false)
|
||||
.withHelpContent(new com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent("Set by the qbit that registered the template. Messages sent from it name that qbit as their sender unless the sender says otherwise.")))
|
||||
.withField(field("description", QFieldType.TEXT));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
|
||||
@@ -270,6 +270,14 @@ public final class EmailService
|
||||
if(templateContent != null)
|
||||
{
|
||||
row.getValues().putAll(templateContent.getValues());
|
||||
if(row.getValue("senderQbit") == null && row.getValue("templateId") != null)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// the qbit that registered the template is the sender when none was named //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> owner = SystemWrites.query(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValue("templateId")), queryTransaction);
|
||||
row.setValue("senderQbit", owner.isEmpty() ? null : owner.getFirst().getValueString("ownerQbit"));
|
||||
}
|
||||
}
|
||||
for(var field : config.getSecurityFields())
|
||||
{
|
||||
|
||||
@@ -36,6 +36,8 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public final class EmailBuiltIns
|
||||
{
|
||||
public static final String OWNER = "qbit-email";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
@@ -55,7 +57,7 @@ public final class EmailBuiltIns
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new InsertInput(EmailTables.TEMPLATE)))
|
||||
{
|
||||
QRecord template = SystemWrites.insert(EmailTables.TEMPLATE, List.of(new QRecord().withValue("name", "email_secure_message")
|
||||
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false)), tx).getRecords().getFirst();
|
||||
.withValue("label", "Secure message").withValue("kind", "MARKDOWN").withValue("category", "SECURITY").withValue("phiApproved", false).withValue("ownerQbit", OWNER)), tx).getRecords().getFirst();
|
||||
if(template.getValue("id") == null || template.getErrors() != null && !template.getErrors().isEmpty())
|
||||
{
|
||||
tx.rollback();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-email starter defaults: one neutral, high-contrast brand to copy or select,
|
||||
-- so the Brands table is not empty and its colors are a safe starting point.
|
||||
-- Include it from the host's master changelog AFTER the changesets that create the
|
||||
-- email_* tables (docs/ddl/email-postgres.sql):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-email/email-defaults.changelog.sql }
|
||||
--
|
||||
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
|
||||
-- Rules for this file:
|
||||
-- * insert-only, and only when the host has no brand at all, so a host that already
|
||||
-- configured its branding is never touched;
|
||||
-- * it never changes how mail looks on deploy: the brand is NOT the default brand
|
||||
-- (is_default = FALSE), so it applies only when someone selects it (a template's brand,
|
||||
-- a request's brand name) or ticks Default Brand on it;
|
||||
-- * no secrets, and portable SQL (PostgreSQL and H2);
|
||||
-- * every changeset has a precondition (sql-check only; table-exists is not portable across
|
||||
-- Liquibase versions), so a host without the table skips it (MARK_RAN).
|
||||
-- Providers, sender addresses, suppression and every runtime table are NOT seeded: a provider
|
||||
-- is the host's own connection (a server and the NAMES of its secret variables). The built-in
|
||||
-- secure-message template is created by the qbit itself on first use and when the Email home opens.
|
||||
-- EmailDefaultsChangelogTest runs this file through Liquibase on a fresh schema.
|
||||
|
||||
-- changeset qbit-email:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand
|
||||
-- comment: A neutral brand (white background, near-black text, blue buttons; light and dark schemes, every pair at least 4.5:1 contrast). Not the default brand, so nothing changes until it is selected.
|
||||
INSERT INTO email_brand (name, label, primary_color, text_color, background_color, button_text_color, dark_primary_color, dark_text_color, dark_background_color, dark_button_text_color, is_default, create_date, modify_date)
|
||||
SELECT 'neutral', 'Neutral', '#2459a6', '#171717', '#ffffff', '#ffffff', '#8ab4f8', '#f5f5f5', '#121212', '#000000', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM email_brand);
|
||||
-- rollback empty
|
||||
@@ -0,0 +1,63 @@
|
||||
bundle: qbit-email
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the Email app and every email_* table, in the QQQ help-bundle format
|
||||
# (qqq#1198). Each table entry says what the table is for and how its rows get
|
||||
# created, so an empty table explains itself. EmailHelp applies it until the core
|
||||
# HelpBundles registry ships in a QQQ release (a stand-in until core #1198 ships).
|
||||
# Summaries stay under 120 characters (EmailTablesGuardrailTest).
|
||||
entries:
|
||||
- key: app:email;slot:help
|
||||
summary: "One email service: a durable outbox, providers, templates with branding, delivery tracking and suppression."
|
||||
content: |
|
||||
The Setup card on the home (email admins) lists every part of Email with its status and the next step. Nothing sends until an enabled default provider exists: that is your mail server or service, so the qbit cannot invent it. A new installation starts with a neutral brand to copy, and the secure-message template appears the first time the home opens.
|
||||
|
||||
- key: table:email_provider;slot:help
|
||||
summary: "A way to send mail: SMTP, SES, SendGrid, Mailgun or Postmark, with limits and the names of its secret variables."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New: pick the kind, the host or region, a from address, and the NAMES of the environment variables that hold the credentials (the secrets themselves are never stored). Turn on Default so sends use it. Run Send Test Email on it to check the connection, then watch its health. This is your own connection, so it starts empty. Empty means nothing can be sent yet.
|
||||
|
||||
- key: table:email_provider_sender_address;slot:help
|
||||
summary: "An address a provider may send from, with a display name. One can be the default for that provider."
|
||||
content: |
|
||||
**How rows get created:** add one from a provider's Sender Addresses list when the provider needs more than its single from address (several mailboxes or brands). A request may name one; otherwise the default is used. Empty is normal: the provider's own from address is used.
|
||||
|
||||
- key: table:email_message;slot:help
|
||||
summary: "The outbox: every email the system queued, with its status, attempts, bounce and complaint dates."
|
||||
content: |
|
||||
**How rows get created:** never by hand. Every send, from any qbit or process, is queued here first (EmailApi.send, the Send Email process, or a messaging provider), then the dispatcher sends it, retries failures and records the result. Actions: Send Now, Resend and Cancel. Empty means nothing has been sent from this system yet.
|
||||
|
||||
- key: table:email_message_attempt;slot:help
|
||||
summary: "One try to hand a message to a provider: outcome, timing and error. Read-only."
|
||||
content: |
|
||||
**How rows get created:** written by the dispatcher each time it tries to send a message. Used for provider rate limits and the hourly chart on the home. Empty means no send has been attempted.
|
||||
|
||||
- key: table:email_template;slot:help
|
||||
summary: "A reusable email: a name, category, brand and the versions that hold its subject and body."
|
||||
content: |
|
||||
**How rows get created:** the qbit creates the secure-message template for you; other qbits register theirs (EmailApi.ensureTemplate) and are named in Owner Qbit; or an administrator adds one with New, then writes its first version and runs Activate Version. Only one version is active at a time. Approve For PHI is a separate, explicit step per version.
|
||||
|
||||
- key: table:email_template_version;slot:help
|
||||
summary: "An immutable version of a template: subject, body, variables and locale. Activate one to use it."
|
||||
content: |
|
||||
**How rows get created:** use New Version on a template, edit the draft (Markdown or MJML body, the variables it accepts, sample values), then Activate Version, which checks the template renders. Activated versions never change. Empty means no template has a version yet.
|
||||
|
||||
- key: table:email_brand;slot:help
|
||||
summary: "Look and footer of branded emails: logo, colors for light and dark, footer text and postal address."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a neutral, high-contrast brand named neutral (not the default, so it changes nothing until you choose it). Copy it for your own: set the logo (https), colors (contrast is checked), footer and postal address, then tick Default Brand to use it for everything that names no brand. Marketing email needs a default brand with a postal address. Without any default the plain neutral look is used.
|
||||
|
||||
- key: table:email_suppression;slot:help
|
||||
summary: "Addresses that must not receive mail: hard bounces, complaints, unsubscribes and manual blocks."
|
||||
content: |
|
||||
**How rows get created:** automatically when a provider reports a hard bounce or complaint through its webhook, when someone unsubscribes, or by an administrator with New (a manual block, for one category or all). Sends to these addresses are skipped and noted on the message. Empty means nobody has bounced, complained or unsubscribed yet.
|
||||
|
||||
- key: table:email_webhook_inbox;slot:help
|
||||
summary: "Provider webhook calls waiting to be processed, kept so none is lost: status, attempts and errors."
|
||||
content: |
|
||||
**How rows get created:** written when a verified provider webhook (delivery, bounce, complaint) arrives at the qbit's webhook path (/qqq/hooks/email by default), then processed in the background. Reprocess retries a failed one. Empty means no provider has called back; set each provider's webhook secret variable and point the provider at the webhook URL.
|
||||
|
||||
- key: table:email_event;slot:help
|
||||
summary: "Delivery tracking events per message: delivered, bounced, complained, opened, clicked."
|
||||
content: |
|
||||
**How rows get created:** written by the webhook processor from provider callbacks. Read-only; each message's timeline is built from these. Empty means no provider has reported anything yet.
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.email;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.render.ContrastCheck;
|
||||
import com.kingsrook.qbits.email.render.EmailBrands;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-email/email-defaults.
|
||||
** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against
|
||||
** the qbit's test schema: the brand appears once, a second update runs nothing,
|
||||
** an edit survives, a host with its own brand or without the table is left
|
||||
** alone, and the seed never changes how mail is rendered.
|
||||
*******************************************************************************/
|
||||
class EmailDefaultsChangelogTest
|
||||
{
|
||||
static final String CHANGELOG = "db/liquibase/qbit-email/email-defaults.changelog.sql";
|
||||
|
||||
/** the tables the changelog seeds. */
|
||||
static final List<String> SEEDED_TABLES = List.of("email_brand");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, update twice: one brand; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("neutral");
|
||||
assertThat(scalar(connection, "SELECT is_default FROM email_brand")).isEqualTo("FALSE");
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE email_brand SET label = 'Ours', is_default = TRUE");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT label FROM email_brand")).isEqualTo("Ours");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already configured a brand (of any name) gets nothing added,
|
||||
** so it can never end up with two default brands.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void leavesAHostWithItsOwnBrandAlone() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('acme', 'Acme', TRUE)");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM email_brand")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM email_brand")).isEqualTo("acme");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling the seed back (Liquibase also rolls back changesets it only marked
|
||||
** as ran) never deletes a brand the host already had.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollbackKeepsHostOwnedRows() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO email_brand (name, label, is_default) VALUES ('neutral', 'Mine', TRUE)");
|
||||
}
|
||||
connection.commit();
|
||||
update(connection);
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(1, "");
|
||||
connection.commit();
|
||||
assertThat(scalar(connection, "SELECT label FROM email_brand WHERE name = 'neutral'")).isEqualTo("Mine");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that has not created the table yet skips the changeset (marked
|
||||
** ran), rather than failing its deploy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aMissingTableMarksTheChangesetRan() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(false))
|
||||
{
|
||||
update(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guarded, and only precondition forms that parse in Liquibase 4 and 5.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
String text = changelogText();
|
||||
assertThat(text).doesNotContain("precondition-table-exists");
|
||||
assertThat(text.split("(?m)^-- changeset ")).hasSize(2);
|
||||
assertThat(text).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM email_brand")
|
||||
.contains("-- rollback ").contains("WHERE NOT EXISTS (SELECT 1 FROM email_brand)");
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(text).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded brand passes the qbit's own contrast checks, and being a
|
||||
** non-default brand it changes nothing about how mail renders until chosen.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theSeededBrandIsSafeAndChangesNothing() throws Exception
|
||||
{
|
||||
try(Connection connection = freshDatabase(true))
|
||||
{
|
||||
update(connection);
|
||||
QRecord brand = new QRecord();
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT * FROM email_brand"))
|
||||
{
|
||||
rs.next();
|
||||
for(String column : List.of("primary_color", "text_color", "background_color", "button_text_color", "dark_primary_color", "dark_text_color", "dark_background_color", "dark_button_text_color"))
|
||||
{
|
||||
brand.setValue(camel(column), rs.getString(column));
|
||||
}
|
||||
}
|
||||
ContrastCheck.validate(brand);
|
||||
EmailBrands.validate(brand);
|
||||
}
|
||||
|
||||
H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "neutral").withValue("label", "Neutral").withValue("primaryColor", "#2459a6")
|
||||
.withValue("isDefault", false));
|
||||
QRecord resolved = EmailBrands.resolve(null, null);
|
||||
assertThat(resolved.getValue("id")).as("the seeded brand is not picked up unless selected").isNull();
|
||||
assertThat(resolved.getValueString("label")).isEqualTo("Email");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** snake_case to camelCase.
|
||||
*******************************************************************************/
|
||||
private static String camel(String column)
|
||||
{
|
||||
StringBuilder camel = new StringBuilder();
|
||||
boolean up = false;
|
||||
for(char c : column.toCharArray())
|
||||
{
|
||||
if(c == '_')
|
||||
{
|
||||
up = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
camel.append(up ? Character.toUpperCase(c) : c);
|
||||
up = false;
|
||||
}
|
||||
}
|
||||
return (camel.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables.
|
||||
*******************************************************************************/
|
||||
static Connection freshDatabase(boolean withTables) throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
|
||||
if(withTables)
|
||||
{
|
||||
String script;
|
||||
try(InputStream in = EmailDefaultsChangelogTest.class.getResourceAsStream("/email-h2.sql"))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : script.split(";"))
|
||||
{
|
||||
if(!sql.isBlank())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the changelog with real Liquibase.
|
||||
*******************************************************************************/
|
||||
static void update(Connection connection) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// not closed: closing Liquibase would close the test connection //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update("");
|
||||
connection.commit();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String changelogText() throws Exception
|
||||
{
|
||||
try(InputStream stream = EmailDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG))
|
||||
{
|
||||
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
return (rs.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -108,7 +108,7 @@ class EmailHomePolishTest
|
||||
assertThat(tiles.get(i).child(0).attr("style")).contains("font-size:1.6rem");
|
||||
assertThat(tiles.get(i).child(0).attr("style").contains("#d64545")).isEqualTo(i == 2 || i == 3);
|
||||
}
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ class EmailHomePolishTest
|
||||
assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry");
|
||||
assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "<broken>", "min-width:0", "overflow-wrap:anywhere")
|
||||
.doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T");
|
||||
assertThat(document.select("section").get(1).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
|
||||
assertThat(document.select("section").get(2).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
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.EmailApiTemplate;
|
||||
import com.kingsrook.qbits.email.app.EmailHome;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus.Item;
|
||||
import com.kingsrook.qbits.email.app.EmailSetupStatus.State;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.template.EmailBuiltIns;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card follows the real configuration (the same provider and brand
|
||||
** resolution the sender uses), the home creates the built-in template, and the
|
||||
** template's owner qbit now decides who a message names as its sender.
|
||||
*******************************************************************************/
|
||||
class EmailSetupTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Item item(String label) throws Exception
|
||||
{
|
||||
return (EmailSetupStatus.load().stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing configured: the provider is the one thing that must be added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void freshInstallNeedsAProvider() throws Exception
|
||||
{
|
||||
assertThat(item("Provider").state()).isEqualTo(State.TODO);
|
||||
assertThat(item("Brands").state()).isEqualTo(State.DEFAULT);
|
||||
assertThat(item("Sender addresses").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item("Marketing email").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item("Scheduler").state()).isEqualTo(State.TODO);
|
||||
assertThat(EmailSetupStatus.load()).allMatch(i -> i.tableName() == null || i.tableName().startsWith("email_"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuring a part moves its line, using the sender's own resolution.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuringMovesTheState() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
assertThat(item("Provider").state()).isEqualTo(State.OK);
|
||||
assertThat(item("Delivery tracking").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(EmailApi.readiness(QContext.getQInstance(), EmailApiCategory.TRANSACTIONAL).ready()).isTrue();
|
||||
|
||||
H2Support.insert(EmailTables.BRAND, new QRecord().withValue("name", "acme").withValue("label", "Acme").withValue("isDefault", true));
|
||||
assertThat(item("Brands").state()).isEqualTo(State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home shows the card to admins and creates the built-in template.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeShowsTheCardAndCreatesTheBuiltInTemplate() throws Exception
|
||||
{
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).isEmpty();
|
||||
String html = EmailHome.overview().getHtml();
|
||||
assertThat(html).contains("Setup").contains("Needs setup").contains("href=\"/app/email_provider\"").contains("Next step");
|
||||
assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("name")).isEqualTo("email_secure_message");
|
||||
assertThat(row.getValueString("ownerQbit")).isEqualTo(EmailBuiltIns.OWNER);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registering qbit becomes the message's sender unless the request names one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theTemplatesOwnerQbitIsTheDefaultSender() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL)
|
||||
.withSubject("Ready").withBody("Hello").withOwnerQbit("orders"));
|
||||
var plain = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-1"));
|
||||
var named = EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withIdempotencyKey("owner-2").withSender("billing", "invoices"));
|
||||
List<QRecord> rows = H2Support.all(EmailTables.MESSAGE);
|
||||
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(plain.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("orders"));
|
||||
assertThat(rows).filteredOn(r -> r.getValueInteger("id").equals(named.messageId())).singleElement().satisfies(r -> assertThat(r.getValueString("senderQbit")).isEqualTo("billing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.email;
|
||||
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.email.metadata.EmailHelp;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated
|
||||
** and the qbit should be fully using them"). For every email_* table:
|
||||
** - runtime code (not just metadata, the app or the home) reads or writes it;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here with
|
||||
** why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class EmailTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "email");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("email_provider", "host connection config: a server and the NAMES of its secret variables (kof22 seeds its provider)"),
|
||||
Map.entry("email_provider_sender_address", "optional: a provider's own from address is used without rows"),
|
||||
Map.entry("email_message", "runtime: every queued email"),
|
||||
Map.entry("email_message_attempt", "runtime: every delivery attempt"),
|
||||
Map.entry("email_template", "created at runtime: the secure-message built-in on first use (EmailBuiltIns), others by the qbits that register them (EmailApi.ensureTemplate)"),
|
||||
Map.entry("email_template_version", "created with a template, and by New Version"),
|
||||
Map.entry("email_suppression", "runtime: bounces, complaints, unsubscribes and manual blocks"),
|
||||
Map.entry("email_webhook_inbox", "runtime: verified provider callbacks"),
|
||||
Map.entry("email_event", "runtime: delivery tracking events"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
|
||||
.map(EmailTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
String constant = constantFor(table);
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> source.contains("EmailTables." + constant)))
|
||||
.as(table + " (EmailTables." + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
String changelog = EmailDefaultsChangelogTest.changelogText();
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
boolean seeded = EmailDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
assertThat(EmailHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : EmailTables.ALL)
|
||||
{
|
||||
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(EmailHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(instance.getApp("email").getHelpContent()).containsKey(EmailHelp.SLOT);
|
||||
assertThat(EmailHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The EmailTables constant name for a table name.
|
||||
*******************************************************************************/
|
||||
private static String constantFor(String table) throws Exception
|
||||
{
|
||||
for(var field : EmailTables.class.getDeclaredFields())
|
||||
{
|
||||
if(field.getType() == String.class && Modifier.isStatic(field.getModifiers()) && table.equals(field.get(null)))
|
||||
{
|
||||
return (field.getName());
|
||||
}
|
||||
}
|
||||
throw (new IllegalStateException("No constant for " + table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user