From 5f6b661d1ad10416fb3db8240daef3815405244e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 09:02:41 -0500 Subject: [PATCH 1/2] feat: public api package for other qbits com.kingsrook.qbits.email.api is the stable contract for other qbits: EmailApi (isInstalled, send, ensureTemplate, message lookups by id and idempotency-key prefix with metadata only, sender addresses, default provider, readiness per category) with its own request, result, template, message and readiness types. A reflection test keeps internal types out of every public signature. The README and DESIGN 2.5 document the two supported routes: core SendMessageAction with the qbitEmail provider for plain mail, and the api package for the rest; internal packages are not API. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 102 ++- docs/DESIGN.md | 40 +- .../kingsrook/qbits/email/api/EmailApi.java | 358 ++++++++++ .../qbits/email/api/EmailApiCategory.java | 33 + .../qbits/email/api/EmailApiMessage.java | 34 + .../qbits/email/api/EmailApiReadiness.java | 39 ++ .../qbits/email/api/EmailApiRequest.java | 633 ++++++++++++++++++ .../qbits/email/api/EmailApiResult.java | 39 ++ .../email/api/EmailApiSenderAddress.java | 29 + .../qbits/email/api/EmailApiStatus.java | 33 + .../qbits/email/api/EmailApiTemplate.java | 319 +++++++++ .../qbits/email/api/EmailApiVariable.java | 30 + .../qbits/email/support/EmailApiMapper.java | 116 ++++ .../qbits/email/api/EmailApiBoundaryTest.java | 219 ++++++ .../qbits/email/api/EmailApiTest.java | 401 +++++++++++ 16 files changed, 2408 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApi.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiCategory.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiResult.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiSenderAddress.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiStatus.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiTemplate.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiVariable.java create mode 100644 src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java create mode 100644 src/test/java/com/kingsrook/qbits/email/api/EmailApiBoundaryTest.java create mode 100644 src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd13d9..f854c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] ### Added +- Stable `com.kingsrook.qbits.email.api` contract for cross-qbit sends, create-once templates, metadata-only message lookups, enabled sender addresses, default provider selection and secret-safe readiness (including marketing and PHI prerequisites). JDK/QQQ-only signatures, complete request/status mapping tests and a reflection package-boundary guard; no new dependencies. +- Documented both supported cross-qbit routes: core-only `SendMessageAction` via `qbitEmail`, and the public API for richer sends and lookups. - Provider sender addresses: child table `email_provider_sender_address` (validated lowercase address, display name, one default, enabled, note; unique per provider; domain bound to the provider's sending domain; admin-only), shown on the provider record. `EmailRequest.withFromAddressId`, `from_address_id` on messages, refusal of a from address that is not an enabled row of the resolved provider, and the provider `from_address` / `from_name` kept as the fallback. - Contingent possible-value filters on the new from-address pickers (send processes by `providerId`, Send Test Email by the acted-on provider, message `fromAddressId` by `providerId`) and on the attempt and event message pickers (by `providerId`) and the suppression source event (by `sourceMessageId`); the send processes also take `providerId`. - Phase (c): authenticated SNS, Postmark, SendGrid and Mailgun webhooks, durable inbox deduplication and Mailgun nonce replay protection; configurable Javalin routes. diff --git a/README.md b/README.md index 0c2218a..b946f92 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,82 @@ and credential failures pause the provider. Tracking is explicitly controlled by `trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c) receiver idempotently when the provided Javalin middleware is present. -## Sending +## Sending from another qbit + +There are two supported routes. Both queue through the durable outbox; a successful +call acknowledges enqueueing, not delivery. + +**1. QQQ core only, for simple subject/body email.** The host installs qbit-email; +the consuming qbit needs no qbit-email dependency or imports. Use +`SendMessageAction` with `messagingProviderName` set to `qbitEmail` (the default +bridge name; hosts can configure another name): + +```java +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; + +new SendMessageAction().execute(new SendMessageInput() + .withMessagingProviderName("qbitEmail") + .withTo(new Party().withAddress("pat@example.com")) + .withSubject("Ready") + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Your export is ready."))); +``` + +**2. The published `com.kingsrook.qbits.email.api` package**, for templates, +idempotency, categories, linked records, sender selection and delivery lookups. +Consumers with a qbit-email dependency import only this package: + +```java +import com.kingsrook.qbits.email.api.EmailApi; +import com.kingsrook.qbits.email.api.EmailApiRequest; +import com.kingsrook.qbits.email.api.EmailApiResult; + +EmailApiResult result = EmailApi.send(new EmailApiRequest() + .withTo("pat@example.com") + .withTemplateName("order_ready") + .withVariable("order_number", "A-1") + .withVariable("customer_name", "Pat") + .withIdempotencyKey("orders:42:ready") + .withLinkedRecord("orders", 42) + .withSender("orders", "ready")); +``` + +`EmailApi.ensureTemplate(EmailApiTemplate)` creates a template once; see +[Templates from another qbit](#templates-from-another-qbit). +`EmailApi.isInstalled(instance)` detects whether the qbit was produced. +`defaultProviderName(instance)` resolves an enabled configured/default provider; +`listSenderAddresses(providerName)` lists enabled sender rows (null selects the +default). `readiness(instance, category)` reports configuration prerequisites, +including MARKETING's public URL, unsubscribe key and default brand postal address. +For a PHI-approved template, use `readiness(instance, category, true)` to also check +the body key and provider PHI/tracking policy. PHI is a template approval state, +not a category. Readiness is a preflight, not proof that a particular send will pass. + +`getMessage(id)` and `findMessagesByIdempotencyKeyPrefix(prefix, limit)` expose only +id, delivery status, category, idempotency key, provider message id, template name, +linked table/id and create/sent/delivered timestamps. They never return addresses, +subjects, bodies or variable snapshots. Prefixes are literal and nonblank; limits +must be positive; results are ordered by ascending message id. Missing messages +return an empty Optional/list. Enqueue results have `QUEUED`, `DUPLICATE`, +`SUPPRESSED` or `REFUSED`; delivery status is a separate string on `EmailApiMessage`. + +Database calls require the caller's initialized `QContext`; methods accepting an +installed instance require it to be the current context instance. Lookups run as +the email system, so callers must authorize any exposure to their own users. +Sending retains the existing permission, schema, PHI, suppression and recipient +rules. Database failures propagate as core `QException`; invalid template definitions +and lookup arguments throw `IllegalArgumentException`. No secret values are exposed. + +The API uses only JDK, QQQ core and its own API types in published signatures. +Changes within a `0.x` minor release line are additive only. Internal packages +(including runtime, configuration, outbox, metadata, support and template classes) +are not API and may change. The internal service details below describe host and +qbit-email implementation behavior, not additional cross-qbit contracts. + +## Internal sending service ```java EmailSendResult result = EmailService.send(new EmailRequest() @@ -271,14 +346,14 @@ preview never stores the result or changes the version. ### Templates from another qbit A qbit that sends its own mail ships the template with its code instead of asking an -administrator to create it. Describe it with `EmailTemplateDefinition` and call -`EmailTemplates.ensure` at startup or before first use: +administrator to create it. Describe it with `EmailApiTemplate` and call +`EmailApi.ensureTemplate` at startup or before first use: ```java -boolean created = EmailTemplates.ensure(new EmailTemplateDefinition() +boolean created = EmailApi.ensureTemplate(new EmailApiTemplate() .withName("order_ready") .withLabel("Order ready") - .withCategory(EmailCategory.TRANSACTIONAL) + .withCategory(EmailApiCategory.TRANSACTIONAL) .withSubject("Order {{order_number}} is ready") .withBody("Hi {{customer_name}}, order {{order_number}} is ready for pickup.") .withVariable("order_number", "Order number", "STRING", true, "PUBLIC") @@ -290,7 +365,7 @@ boolean created = EmailTemplates.ensure(new EmailTemplateDefinition() `kind` defaults to MARKDOWN (MJML and HTML are also accepted), `withTextBody` is optional, and `withVariablesSchema(json)` replaces `withVariable` when you already have -the JSON. When no template of that name exists, `ensure` validates the definition like +the JSON. When no template of that name exists, `ensureTemplate` validates the definition like Activate Version (syntax, schema, a render with the samples, MJML compile), creates the template and an ACTIVE version 1 as the qbit, and returns true. When one exists it does nothing and returns false, so people's edits and later versions are never overwritten; @@ -449,17 +524,10 @@ The bridge recursively maps Party/MultiParty TO, CC, BCC, FROM and REPLY_TO role TEXT/HTML alternatives. REFUSED throws `QException`; successful calls acknowledge queueing, not delivery. The built-in EMAIL and SES types are unchanged. -For schema-aware core bridge calls, use the typed extension: - -```java -new SendMessageAction().execute(new EmailTemplatedMessageInput() - .withEmailRequest(new EmailRequest().withTemplateName("email_secure_message") - .withTo("pat@example.com").withVariable("link", "https://app.example.com/inbox")) - .withMessagingProviderName("qbitEmail")); -``` - -Ordinary core `SendMessageInput` remains ad-hoc and cannot infer classifications -from arbitrary pre-rendered text. Use the typed extension for classified variables. +For schema-aware calls from another qbit, use `EmailApi.send` with an +`EmailApiRequest` and declared template variables as described above. Ordinary +core `SendMessageInput` is ad-hoc and cannot infer classifications from arbitrary +pre-rendered text. The existing typed bridge extension is an internal interface. ## Configuration diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 47524f8..651602b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -44,7 +44,7 @@ unsubscribe and PHI rules. ## 2. Sending API -### 2.1 `EmailService` (Java, for every consumer) +### 2.1 `EmailService` (internal Java enqueue service) ```java EmailSendResult result = EmailService.send(new EmailRequest() @@ -96,9 +96,45 @@ template may set `edit_permission_name` to restrict who edits it (e.g. marketing - Attachments are stored through qbit-object-storage when it is installed, else refused with a plain message (no blobs in the outbox, nothing on local disk: D11). - Callers (qbit-alerts `EmailChannel` and `NotificationEmailer`, qbit-marketing - `QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailService` + `QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailApi` in phase (d) for templates, categories, idempotency and linked records. +### 2.5 Public API + +`com.kingsrook.qbits.email.api` is the stable Java contract for consuming qbits. +Changes within a `0.x` minor release line are additive only. Public/protected +signatures contain only JDK, QQQ core or API-package types; internal packages are +not API and may change. A reflection test discovers every compiled API class and +checks signatures, including generic arguments, bounds and nested types. + +- `EmailApi.send(EmailApiRequest)` maps to the existing outbox service, preserving + permissions, schema/PHI validation, suppression and one-recipient rules. Requests + cover recipients/sender selection, body alternatives, template variables and record + provenance, category/provider/brand/locale, idempotency, linked records, producer + identity and scheduling. Results expose message id, enqueue status and reasons. +- `ensureTemplate(EmailApiTemplate)` delegates create-once template registration; + `EmailApiVariable` carries the variable contract. Existing content is never + overwritten, and the API does not confer PHI approval. +- `getMessage` and bounded, literal `findMessagesByIdempotencyKeyPrefix` perform + system reads of an explicit metadata projection: id/status/category, idempotency + and provider ids, template name, linked record and create/sent/delivered dates. + No recipients, subjects, bodies or snapshots are fetched or returned by lookups. + Callers own authorization before presenting metadata to users. +- `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace + consumer access to runtime/configuration/tables. Readiness checks an enabled + default provider; MARKETING also needs a valid publicBaseUrl, available unsubscribe + key and default brand postal address. The PHI overload checks body key availability + and provider PHI/tracking policy. Reasons never contain resolved secret values. + PHI is a template state, so it is an explicit option rather than a fifth category. +- Database operations use the current `QContext`; an installed instance argument + must match that context. Database errors remain core `QException`. Readiness does + not validate an individual request or guarantee delivery. + +For simple subject/body sends, consumers need no qbit-email dependency: use core +`SendMessageAction` with `messagingProviderName = "qbitEmail"` (or the host's +configured bridge name). Use the API package for templates, idempotency, links and +lookups. Both routes enqueue; neither sends inline. + ## 3. Providers and transports ### 3.1 `email_provider` diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java new file mode 100644 index 0000000..d5878a0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java @@ -0,0 +1,358 @@ +/* + * 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.api; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import com.kingsrook.qbits.email.EmailRuntime; +import com.kingsrook.qbits.email.metadata.EmailTableCustomizers; +import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qbits.email.outbox.EmailService; +import com.kingsrook.qbits.email.support.EmailApiMapper; +import com.kingsrook.qbits.email.support.EmailStore; +import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qbits.email.template.EmailTemplateRenderer; +import com.kingsrook.qbits.email.template.EmailTemplates; +import com.kingsrook.qbits.email.template.VariableSchema; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +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.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + + +/******************************************************************************* + ** Stable contract for other qbits. Changes within a 0.x minor release line are + ** additive only; implementation packages are not API and may change. + ** Database operations use the current QContext. Reads run as the email system + ** and return only the documented projection; callers must authorize any + ** exposure of this metadata to their own users. Sends retain all existing + ** permission, validation, PHI, suppression and recipient checks. + *******************************************************************************/ +public final class EmailApi +{ + private static final Set MESSAGE_FIELDS = Set.of("id", "status", "category", "idempotencyKey", "providerMessageId", + "templateId", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private EmailApi() + { + } + + + + /******************************************************************************* + ** True only when the email qbit was produced in the supplied instance. + ** Does not require a current context; null is not installed. + *******************************************************************************/ + public static boolean isInstalled(QInstance instance) + { + return (EmailRuntime.config(instance) != null); + } + + + + /******************************************************************************* + ** Queue a message. QUEUED is not a delivery acknowledgement. Null requests + ** are refused by the same service used by core messaging. + *******************************************************************************/ + public static EmailApiResult send(EmailApiRequest request) throws QException + { + return (EmailApiMapper.result(EmailService.send(EmailApiMapper.request(request)))); + } + + + + /******************************************************************************* + ** Create a validated template and active version once. Returns false when + ** it already exists. Invalid definitions throw IllegalArgumentException. + ** Does not grant PHI approval or overwrite administrator edits. + *******************************************************************************/ + public static boolean ensureTemplate(EmailApiTemplate template) throws QException + { + return (EmailTemplates.ensure(EmailApiMapper.template(template))); + } + + + + /******************************************************************************* + ** System lookup of delivery metadata. Null and unknown ids return empty. + *******************************************************************************/ + public static Optional getMessage(Integer messageId) throws QException + { + if(messageId == null) + { + return (Optional.empty()); + } + List rows = read(EmailTables.MESSAGE, EmailStore.equal("id", messageId).withLimit(1), MESSAGE_FIELDS); + return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst()))); + } + + + + /******************************************************************************* + ** System lookup by literal prefix, ordered by ascending message id. Prefix + ** must be nonblank and limit positive; invalid arguments are rejected. + ** Returns at most limit rows, without message content or recipient data. + *******************************************************************************/ + public static List findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException + { + if(prefix == null || prefix.isBlank() || limit < 1) + { + throw (new IllegalArgumentException("A nonblank idempotency key prefix and a positive limit are required")); + } + /////////////////////////////////////////////////////////////////////////// + // QQQ SQL STARTS_WITH uses LIKE without escaping. Narrow by the literal // + // portion before any wildcard, then check the full prefix in Java. Page // + // by id so wildcard false positives cannot consume the requested limit. // + /////////////////////////////////////////////////////////////////////////// + String candidatePrefix = prefix.split("[%_\\\\]", 2)[0]; + List messages = new ArrayList<>(); + int lastId = 0; + while(messages.size() < limit) + { + int batchSize = Math.min(500, limit - messages.size()); + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.STARTS_WITH, candidatePrefix)) + .withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)) + .withOrderBy(new QFilterOrderBy("id")).withLimit(batchSize); + List rows = read(EmailTables.MESSAGE, filter, MESSAGE_FIELDS); + for(QRecord row : rows) + { + lastId = row.getValueInteger("id"); + if(row.getValueString("idempotencyKey") != null && row.getValueString("idempotencyKey").startsWith(prefix)) + { + messages.add(message(row)); + } + } + if(rows.size() < batchSize) + { + break; + } + } + return (List.copyOf(messages)); + } + + + + /******************************************************************************* + ** Enabled sender rows of an enabled named provider, ordered by id. Null + ** selects the effective default provider. No match returns an empty list. + ** The provider's legacy from-address fallback is not a sender row. + *******************************************************************************/ + public static List listSenderAddresses(String providerName) throws QException + { + QRecord provider = provider(QContext.getQInstance(), providerName); + if(provider == null) + { + return (List.of()); + } + QQueryFilter filter = EmailStore.equal("providerId", provider.getValueInteger("id")) + .withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")); + return (read(EmailTables.SENDER, filter, Set.of("id", "address", "displayName", "isDefault")) + .stream().map(row -> new EmailApiSenderAddress(row.getValueInteger("id"), row.getValueString("address"), + row.getValueString("displayName"), Boolean.TRUE.equals(row.getValueBoolean("isDefault")))).toList()); + } + + + + /******************************************************************************* + ** Effective enabled default: the configured name takes precedence over the + ** default row. Empty when absent, disabled or ambiguous. An installed + ** instance must be the current QContext instance, otherwise this throws. + *******************************************************************************/ + public static Optional defaultProviderName(QInstance instance) throws QException + { + QRecord provider = provider(instance, null); + return (provider == null ? Optional.empty() : Optional.of(provider.getValueString("name"))); + } + + + + /******************************************************************************* + ** Preflight the default sending configuration for a category. Null category + ** means TRANSACTIONAL. This does not validate a particular template, + ** recipient or permission, or contact a transport. PHI callers use the + ** overload with phiApproved set to true. + *******************************************************************************/ + public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category) throws QException + { + return (readiness(instance, category, false)); + } + + + + /******************************************************************************* + ** Also check prerequisites for a PHI-approved template. PHI is a template + ** property, not an email category. Checks body key availability and provider + ** PHI/tracking policy; does not approve a template. Resolver failures are + ** reported as missing keys without exposing exception details or values. + *******************************************************************************/ + public static EmailApiReadiness readiness(QInstance instance, EmailApiCategory category, boolean phiApproved) throws QException + { + List reasons = new ArrayList<>(); + var config = EmailRuntime.config(instance); + if(config == null) + { + return (new EmailApiReadiness(false, List.of("Email qbit is not installed"))); + } + QRecord provider = provider(instance, null); + if(provider == null) + { + reasons.add("An enabled named or default provider is required"); + } + else + { + if(!EmailTableCustomizers.validateProvider(provider, config).isEmpty()) + { + reasons.add("The default provider configuration is invalid"); + } + if(phiApproved) + { + try + { + EmailTemplateRenderer.checkProvider(provider); + } + catch(IllegalArgumentException e) + { + reasons.add("PHI email needs a PHI-enabled provider with open and click tracking disabled"); + } + } + } + if(category == EmailApiCategory.MARKETING) + { + try + { + if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank()) + { + throw (new IllegalArgumentException()); + } + VariableSchema.url(config.getPublicBaseUrl()); + } + catch(IllegalArgumentException e) + { + reasons.add("MARKETING email needs a valid publicBaseUrl"); + } + if(!hasSecret(config.getSecretResolver(), "QQQ_EMAIL_UNSUBSCRIBE_KEY")) + { + reasons.add("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY"); + } + List brands = read(EmailTables.BRAND, EmailStore.equal("isDefault", true).withLimit(2), Set.of("id", "postalAddress")); + if(brands.size() != 1 || brands.getFirst().getValueString("postalAddress") == null || brands.getFirst().getValueString("postalAddress").isBlank()) + { + reasons.add("MARKETING email needs a default brand with a postal address"); + } + } + if(phiApproved && !hasSecret(config.getSecretResolver(), "QQQ_EMAIL_BODY_KEY")) + { + reasons.add("PHI email needs QQQ_EMAIL_BODY_KEY"); + } + return (new EmailApiReadiness(reasons.isEmpty(), reasons)); + } + + + + /******************************************************************************* + ** Presence only; neither retain values nor expose resolver failures. + *******************************************************************************/ + private static boolean hasSecret(Function resolver, String name) + { + try + { + String value = resolver.apply(name); + return (value != null && !value.isBlank()); + } + catch(RuntimeException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** Match the enqueue service's configured-name/default-row precedence. + ** Refuse a mismatched context instead of reading another instance's data. + *******************************************************************************/ + private static QRecord provider(QInstance instance, String name) throws QException + { + var config = EmailRuntime.config(instance); + if(config == null) + { + return (null); + } + if(instance != QContext.getQInstance()) + { + throw (new IllegalArgumentException("The supplied instance must be the current QContext instance")); + } + String effectiveName = name == null ? config.getDefaultProviderName() : name; + QQueryFilter filter = EmailStore.equal("enabled", true) + .withCriteria(new QFilterCriteria(effectiveName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, effectiveName == null ? true : effectiveName)) + .withLimit(2); + List providers = SystemWrites.query(EmailTables.PROVIDER, filter); + return (providers.size() == 1 ? providers.getFirst() : null); + } + + + + /******************************************************************************* + ** Read an explicit projection; message bodies are never fetched here. + *******************************************************************************/ + private static List read(String table, QQueryFilter filter, Set fields) throws QException + { + return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter) + .withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords())); + } + + + + /******************************************************************************* + ** Resolve the template name and copy only the approved metadata fields. + *******************************************************************************/ + private static EmailApiMessage message(QRecord row) throws QException + { + String templateName = null; + if(row.getValueInteger("templateId") != null) + { + List templates = read(EmailTables.TEMPLATE, EmailStore.equal("id", row.getValueInteger("templateId")).withLimit(1), Set.of("id", "name")); + if(!templates.isEmpty()) + { + templateName = templates.getFirst().getValueString("name"); + } + } + String category = row.getValueString("category"); + return (new EmailApiMessage(row.getValueInteger("id"), row.getValueString("status"), category == null ? null : EmailApiCategory.valueOf(category), + row.getValueString("idempotencyKey"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"), + row.getValueString("linkedRecordId"), row.getValueInstant("createDate"), row.getValueInstant("sentDate"), row.getValueInstant("deliveredDate"))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiCategory.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiCategory.java new file mode 100644 index 0000000..e9cd77c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiCategory.java @@ -0,0 +1,33 @@ +/* + * 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.api; + + +/******************************************************************************* + ** Stable sending categories; PHI approval belongs to the template, not the category. + *******************************************************************************/ +public enum EmailApiCategory +{ + TRANSACTIONAL, + NOTIFICATION, + MARKETING, + SECURITY +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java new file mode 100644 index 0000000..4f1e5af --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java @@ -0,0 +1,34 @@ +/* + * 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.api; + + +import java.time.Instant; + + +/******************************************************************************* + ** Immutable delivery metadata. No addresses, subject, bodies or variable snapshots. + ** Status is the persisted delivery state, distinct from EmailApiStatus enqueue outcomes. + *******************************************************************************/ +public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String providerMessageId, + String templateName, String linkedTableName, String linkedRecordId, Instant createDate, Instant sentDate, Instant deliveredDate) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java new file mode 100644 index 0000000..a6f6157 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiReadiness.java @@ -0,0 +1,39 @@ +/* + * 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.api; + + +import java.util.List; + + +/******************************************************************************* + ** Configuration preflight, not a delivery guarantee. Reasons never contain secret values. + *******************************************************************************/ +public record EmailApiReadiness(boolean ready, List reasons) +{ + /******************************************************************************* + ** Copy configuration reasons. + *******************************************************************************/ + public EmailApiReadiness + { + reasons = List.copyOf(reasons); + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java new file mode 100644 index 0000000..3cea3ef --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java @@ -0,0 +1,633 @@ +/* + * 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.api; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +/******************************************************************************* + ** Stable enqueue request. One send always goes through the outbox. + ** Null category means TRANSACTIONAL for ad-hoc mail; templates select their category. + ** Recipient lists and variable maps are mutable request-building state. + *******************************************************************************/ +public final class EmailApiRequest +{ + private List to = new ArrayList<>(); + private List cc = new ArrayList<>(); + private List bcc = new ArrayList<>(); + private String from; + private Integer fromAddressId; + private String replyTo; + private String subject; + private String markdownBody; + private String htmlBody; + private String textBody; + private String providerName; + private String templateName; + private String brandName; + private String locale = "en"; + private EmailApiCategory category; + private String idempotencyKey; + private Instant sendAfter; + private String linkedTableName; + private String linkedRecordId; + private String senderQbit; + private String senderProcess; + private Map variables = new LinkedHashMap<>(); + private Map recordVariables = new LinkedHashMap<>(); + + + + /******************************************************************************* + ** Getter for to. + *******************************************************************************/ + public List getTo() + { + return (to); + } + + + + /******************************************************************************* + ** Fluent setter for to. + *******************************************************************************/ + public EmailApiRequest withTo(List to) + { + this.to = to; + return (this); + } + + + + /******************************************************************************* + ** Getter for cc. + *******************************************************************************/ + public List getCc() + { + return (cc); + } + + + + /******************************************************************************* + ** Fluent setter for cc. + *******************************************************************************/ + public EmailApiRequest withCc(List cc) + { + this.cc = cc; + return (this); + } + + + + /******************************************************************************* + ** Getter for bcc. + *******************************************************************************/ + public List getBcc() + { + return (bcc); + } + + + + /******************************************************************************* + ** Fluent setter for bcc. + *******************************************************************************/ + public EmailApiRequest withBcc(List bcc) + { + this.bcc = bcc; + return (this); + } + + + + /******************************************************************************* + ** Getter for from. + *******************************************************************************/ + public String getFrom() + { + return (from); + } + + + + /******************************************************************************* + ** Fluent setter for from. + *******************************************************************************/ + public EmailApiRequest withFrom(String from) + { + this.from = from; + return (this); + } + + + + /******************************************************************************* + ** Getter for fromAddressId. + *******************************************************************************/ + public Integer getFromAddressId() + { + return (fromAddressId); + } + + + + /******************************************************************************* + ** Fluent setter for fromAddressId. + *******************************************************************************/ + public EmailApiRequest withFromAddressId(Integer fromAddressId) + { + this.fromAddressId = fromAddressId; + return (this); + } + + + + /******************************************************************************* + ** Getter for replyTo. + *******************************************************************************/ + public String getReplyTo() + { + return (replyTo); + } + + + + /******************************************************************************* + ** Fluent setter for replyTo. + *******************************************************************************/ + public EmailApiRequest withReplyTo(String replyTo) + { + this.replyTo = replyTo; + return (this); + } + + + + /******************************************************************************* + ** Getter for subject. + *******************************************************************************/ + public String getSubject() + { + return (subject); + } + + + + /******************************************************************************* + ** Fluent setter for subject. + *******************************************************************************/ + public EmailApiRequest withSubject(String subject) + { + this.subject = subject; + return (this); + } + + + + /******************************************************************************* + ** Getter for markdownBody. + *******************************************************************************/ + public String getMarkdownBody() + { + return (markdownBody); + } + + + + /******************************************************************************* + ** Fluent setter for markdownBody. + *******************************************************************************/ + public EmailApiRequest withMarkdownBody(String markdownBody) + { + this.markdownBody = markdownBody; + return (this); + } + + + + /******************************************************************************* + ** Getter for htmlBody. + *******************************************************************************/ + public String getHtmlBody() + { + return (htmlBody); + } + + + + /******************************************************************************* + ** Fluent setter for htmlBody. + *******************************************************************************/ + public EmailApiRequest withHtmlBody(String htmlBody) + { + this.htmlBody = htmlBody; + return (this); + } + + + + /******************************************************************************* + ** Getter for textBody. + *******************************************************************************/ + public String getTextBody() + { + return (textBody); + } + + + + /******************************************************************************* + ** Fluent setter for textBody. + *******************************************************************************/ + public EmailApiRequest withTextBody(String textBody) + { + this.textBody = textBody; + return (this); + } + + + + /******************************************************************************* + ** Getter for providerName. + *******************************************************************************/ + public String getProviderName() + { + return (providerName); + } + + + + /******************************************************************************* + ** Fluent setter for providerName. + *******************************************************************************/ + public EmailApiRequest withProviderName(String providerName) + { + this.providerName = providerName; + return (this); + } + + + + /******************************************************************************* + ** Getter for templateName. + *******************************************************************************/ + public String getTemplateName() + { + return (templateName); + } + + + + /******************************************************************************* + ** Fluent setter for templateName. + *******************************************************************************/ + public EmailApiRequest withTemplateName(String templateName) + { + this.templateName = templateName; + return (this); + } + + + + /******************************************************************************* + ** Getter for brandName. + *******************************************************************************/ + public String getBrandName() + { + return (brandName); + } + + + + /******************************************************************************* + ** Fluent setter for brandName. + *******************************************************************************/ + public EmailApiRequest withBrandName(String brandName) + { + this.brandName = brandName; + return (this); + } + + + + /******************************************************************************* + ** Getter for locale. + *******************************************************************************/ + public String getLocale() + { + return (locale); + } + + + + /******************************************************************************* + ** Fluent setter for locale. + *******************************************************************************/ + public EmailApiRequest withLocale(String locale) + { + this.locale = locale; + return (this); + } + + + + /******************************************************************************* + ** Getter for category. + *******************************************************************************/ + public EmailApiCategory getCategory() + { + return (category); + } + + + + /******************************************************************************* + ** Fluent setter for category. + *******************************************************************************/ + public EmailApiRequest withCategory(EmailApiCategory category) + { + this.category = category; + return (this); + } + + + + /******************************************************************************* + ** Getter for idempotencyKey. + *******************************************************************************/ + public String getIdempotencyKey() + { + return (idempotencyKey); + } + + + + /******************************************************************************* + ** Fluent setter for idempotencyKey. + *******************************************************************************/ + public EmailApiRequest withIdempotencyKey(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for sendAfter. + *******************************************************************************/ + public Instant getSendAfter() + { + return (sendAfter); + } + + + + /******************************************************************************* + ** Fluent setter for sendAfter. + *******************************************************************************/ + public EmailApiRequest withSendAfter(Instant sendAfter) + { + this.sendAfter = sendAfter; + return (this); + } + + + + /******************************************************************************* + ** Getter for linkedTableName. + *******************************************************************************/ + public String getLinkedTableName() + { + return (linkedTableName); + } + + + + /******************************************************************************* + ** Fluent setter for linkedTableName. + *******************************************************************************/ + public EmailApiRequest withLinkedTableName(String linkedTableName) + { + this.linkedTableName = linkedTableName; + return (this); + } + + + + /******************************************************************************* + ** Getter for linkedRecordId. + *******************************************************************************/ + public String getLinkedRecordId() + { + return (linkedRecordId); + } + + + + /******************************************************************************* + ** Fluent setter for linkedRecordId. + *******************************************************************************/ + public EmailApiRequest withLinkedRecordId(String linkedRecordId) + { + this.linkedRecordId = linkedRecordId; + return (this); + } + + + + /******************************************************************************* + ** Getter for senderQbit. + *******************************************************************************/ + public String getSenderQbit() + { + return (senderQbit); + } + + + + /******************************************************************************* + ** Fluent setter for senderQbit. + *******************************************************************************/ + public EmailApiRequest withSenderQbit(String senderQbit) + { + this.senderQbit = senderQbit; + return (this); + } + + + + /******************************************************************************* + ** Getter for senderProcess. + *******************************************************************************/ + public String getSenderProcess() + { + return (senderProcess); + } + + + + /******************************************************************************* + ** Fluent setter for senderProcess. + *******************************************************************************/ + public EmailApiRequest withSenderProcess(String senderProcess) + { + this.senderProcess = senderProcess; + return (this); + } + + + + /******************************************************************************* + ** Getter for variables. + *******************************************************************************/ + public Map getVariables() + { + return (variables); + } + + + + /******************************************************************************* + ** Fluent setter for variables. + *******************************************************************************/ + public EmailApiRequest withVariables(Map variables) + { + this.variables = variables; + return (this); + } + + + + /******************************************************************************* + ** Getter for recordVariables. + *******************************************************************************/ + public Map getRecordVariables() + { + return (recordVariables); + } + + + + /******************************************************************************* + ** Fluent setter for recordVariables. + *******************************************************************************/ + public EmailApiRequest withRecordVariables(Map recordVariables) + { + this.recordVariables = recordVariables; + return (this); + } + + + + /******************************************************************************* + ** Append to recipients. Sending still enforces category and PHI recipient rules. + *******************************************************************************/ + public EmailApiRequest withTo(String... addresses) + { + to.addAll(List.of(addresses)); + return (this); + } + + + + /******************************************************************************* + ** Append cc recipients. Sending still enforces category and PHI recipient rules. + *******************************************************************************/ + public EmailApiRequest withCc(String... addresses) + { + cc.addAll(List.of(addresses)); + return (this); + } + + + + /******************************************************************************* + ** Append bcc recipients. Sending still enforces category and PHI recipient rules. + *******************************************************************************/ + public EmailApiRequest withBcc(String... addresses) + { + bcc.addAll(List.of(addresses)); + return (this); + } + + + + /******************************************************************************* + ** Link the message to an application record. + *******************************************************************************/ + public EmailApiRequest withLinkedRecord(String table, Object id) + { + linkedTableName = table; + linkedRecordId = id == null ? null : String.valueOf(id); + return (this); + } + + + + /******************************************************************************* + ** Identify the producing qbit and process. + *******************************************************************************/ + public EmailApiRequest withSender(String qbit, String process) + { + senderQbit = qbit; + senderProcess = process; + return (this); + } + + + + /******************************************************************************* + ** Bind one variable; template schema and classification checks still apply. + *******************************************************************************/ + public EmailApiRequest withVariable(String name, Serializable value) + { + variables.put(name, value); + return (this); + } + + + + /******************************************************************************* + ** Bind record provenance without reading or snapshotting the record. + *******************************************************************************/ + public EmailApiRequest withRecordVariable(String name, String table, Serializable pk) + { + recordVariables.put(name, new RecordVariable(table, pk)); + return (this); + } + + + + /******************************************************************************* + ** A record reference; values are resolved and classified by the sending service. + *******************************************************************************/ + public record RecordVariable(String table, Serializable id) + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiResult.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiResult.java new file mode 100644 index 0000000..20993fa --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiResult.java @@ -0,0 +1,39 @@ +/* + * 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.api; + + +import java.util.List; + + +/******************************************************************************* + ** Immutable enqueue result with plain refusal reasons. + *******************************************************************************/ +public record EmailApiResult(Integer messageId, EmailApiStatus status, List reasons) +{ + /******************************************************************************* + ** Copy reasons so callers cannot mutate an outcome. + *******************************************************************************/ + public EmailApiResult + { + reasons = List.copyOf(reasons); + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiSenderAddress.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiSenderAddress.java new file mode 100644 index 0000000..86e2833 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiSenderAddress.java @@ -0,0 +1,29 @@ +/* + * 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.api; + + +/******************************************************************************* + ** An enabled provider sender address; no provider credentials. + *******************************************************************************/ +public record EmailApiSenderAddress(Integer id, String address, String displayName, boolean isDefault) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiStatus.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiStatus.java new file mode 100644 index 0000000..26a5d90 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiStatus.java @@ -0,0 +1,33 @@ +/* + * 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.api; + + +/******************************************************************************* + ** Enqueue outcomes. QUEUED acknowledges storage, not delivery. + *******************************************************************************/ +public enum EmailApiStatus +{ + QUEUED, + DUPLICATE, + SUPPRESSED, + REFUSED +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiTemplate.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiTemplate.java new file mode 100644 index 0000000..b59a149 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiTemplate.java @@ -0,0 +1,319 @@ +/* + * 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.api; + + +import java.util.ArrayList; +import java.util.List; + + +/******************************************************************************* + ** A create-once template definition. Existing templates and versions are never overwritten. + ** New templates are not PHI approved; approval remains a separately authorized operation. + *******************************************************************************/ +public final class EmailApiTemplate +{ + private String name; + private String label; + private EmailApiCategory category; + private String kind = "MARKDOWN"; + private String subject; + private String body; + private String textBody; + private String variablesSchema; + private List variables = new ArrayList<>(); + private String sampleVariables; + private String ownerQbit; + private String editPermissionName; + + + + /******************************************************************************* + ** Getter for name. + *******************************************************************************/ + public String getName() + { + return (name); + } + + + + /******************************************************************************* + ** Fluent setter for name. + *******************************************************************************/ + public EmailApiTemplate withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Getter for label. + *******************************************************************************/ + public String getLabel() + { + return (label); + } + + + + /******************************************************************************* + ** Fluent setter for label. + *******************************************************************************/ + public EmailApiTemplate withLabel(String label) + { + this.label = label; + return (this); + } + + + + /******************************************************************************* + ** Getter for category. + *******************************************************************************/ + public EmailApiCategory getCategory() + { + return (category); + } + + + + /******************************************************************************* + ** Fluent setter for category. + *******************************************************************************/ + public EmailApiTemplate withCategory(EmailApiCategory category) + { + this.category = category; + return (this); + } + + + + /******************************************************************************* + ** Getter for kind. + *******************************************************************************/ + public String getKind() + { + return (kind); + } + + + + /******************************************************************************* + ** Fluent setter for kind. + *******************************************************************************/ + public EmailApiTemplate withKind(String kind) + { + this.kind = kind; + return (this); + } + + + + /******************************************************************************* + ** Getter for subject. + *******************************************************************************/ + public String getSubject() + { + return (subject); + } + + + + /******************************************************************************* + ** Fluent setter for subject. + *******************************************************************************/ + public EmailApiTemplate withSubject(String subject) + { + this.subject = subject; + return (this); + } + + + + /******************************************************************************* + ** Getter for body. + *******************************************************************************/ + public String getBody() + { + return (body); + } + + + + /******************************************************************************* + ** Fluent setter for body. + *******************************************************************************/ + public EmailApiTemplate withBody(String body) + { + this.body = body; + return (this); + } + + + + /******************************************************************************* + ** Getter for textBody. + *******************************************************************************/ + public String getTextBody() + { + return (textBody); + } + + + + /******************************************************************************* + ** Fluent setter for textBody. + *******************************************************************************/ + public EmailApiTemplate withTextBody(String textBody) + { + this.textBody = textBody; + return (this); + } + + + + /******************************************************************************* + ** Getter for variablesSchema. + *******************************************************************************/ + public String getVariablesSchema() + { + return (variablesSchema); + } + + + + /******************************************************************************* + ** Fluent setter for variablesSchema. + *******************************************************************************/ + public EmailApiTemplate withVariablesSchema(String variablesSchema) + { + this.variablesSchema = variablesSchema; + return (this); + } + + + + /******************************************************************************* + ** Getter for variables. + *******************************************************************************/ + public List getVariables() + { + return (variables); + } + + + + /******************************************************************************* + ** Fluent setter for variables. + *******************************************************************************/ + public EmailApiTemplate withVariables(List variables) + { + this.variables = variables; + return (this); + } + + + + /******************************************************************************* + ** Getter for sampleVariables. + *******************************************************************************/ + public String getSampleVariables() + { + return (sampleVariables); + } + + + + /******************************************************************************* + ** Fluent setter for sampleVariables. + *******************************************************************************/ + public EmailApiTemplate withSampleVariables(String sampleVariables) + { + this.sampleVariables = sampleVariables; + return (this); + } + + + + /******************************************************************************* + ** Getter for ownerQbit. + *******************************************************************************/ + public String getOwnerQbit() + { + return (ownerQbit); + } + + + + /******************************************************************************* + ** Fluent setter for ownerQbit. + *******************************************************************************/ + public EmailApiTemplate withOwnerQbit(String ownerQbit) + { + this.ownerQbit = ownerQbit; + return (this); + } + + + + /******************************************************************************* + ** Getter for editPermissionName. + *******************************************************************************/ + public String getEditPermissionName() + { + return (editPermissionName); + } + + + + /******************************************************************************* + ** Fluent setter for editPermissionName. + *******************************************************************************/ + public EmailApiTemplate withEditPermissionName(String editPermissionName) + { + this.editPermissionName = editPermissionName; + return (this); + } + + + + /******************************************************************************* + ** Append a typed variable declaration. Use this or variablesSchema, not both. + *******************************************************************************/ + public EmailApiTemplate withVariable(EmailApiVariable variable) + { + variables.add(variable); + return (this); + } + + + + /******************************************************************************* + ** Append a variable declaration using the template schema vocabulary. + *******************************************************************************/ + public EmailApiTemplate withVariable(String name, String label, String type, boolean required, String classification) + { + return (withVariable(new EmailApiVariable(name, label, type, required, classification))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiVariable.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiVariable.java new file mode 100644 index 0000000..79c446f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiVariable.java @@ -0,0 +1,30 @@ +/* + * 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.api; + + +/******************************************************************************* + ** Template variable declaration. Types: STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, + ** URL, LIST, RECORD. Classification: PUBLIC, CONFIDENTIAL, PII, PHI or null. + *******************************************************************************/ +public record EmailApiVariable(String name, String label, String type, boolean required, String classification) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java b/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java new file mode 100644 index 0000000..55fef89 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java @@ -0,0 +1,116 @@ +/* + * 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.support; + + +import com.kingsrook.qbits.email.api.EmailApiRequest; +import com.kingsrook.qbits.email.api.EmailApiResult; +import com.kingsrook.qbits.email.api.EmailApiStatus; +import com.kingsrook.qbits.email.api.EmailApiTemplate; +import com.kingsrook.qbits.email.outbox.EmailCategory; +import com.kingsrook.qbits.email.outbox.EmailRequest; +import com.kingsrook.qbits.email.outbox.EmailSendResult; +import com.kingsrook.qbits.email.template.EmailTemplateDefinition; + + +/******************************************************************************* + ** Internal adapter between the stable contract and the implementation. + *******************************************************************************/ +public final class EmailApiMapper +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private EmailApiMapper() + { + } + + + + /******************************************************************************* + ** Map every published request field without bypassing send validation. + *******************************************************************************/ + public static EmailRequest request(EmailApiRequest request) + { + if(request == null) + { + return (null); + } + EmailRequest mapped = new EmailRequest() + .withTo(request.getTo()) + .withCc(request.getCc()) + .withBcc(request.getBcc()) + .withFrom(request.getFrom()) + .withFromAddressId(request.getFromAddressId()) + .withReplyTo(request.getReplyTo()) + .withSubject(request.getSubject()) + .withMarkdownBody(request.getMarkdownBody()) + .withHtmlBody(request.getHtmlBody()) + .withTextBody(request.getTextBody()) + .withProviderName(request.getProviderName()) + .withTemplateName(request.getTemplateName()) + .withBrandName(request.getBrandName()) + .withLocale(request.getLocale()) + .withIdempotencyKey(request.getIdempotencyKey()) + .withSendAfter(request.getSendAfter()) + .withLinkedTableName(request.getLinkedTableName()) + .withLinkedRecordId(request.getLinkedRecordId()) + .withSenderQbit(request.getSenderQbit()) + .withSenderProcess(request.getSenderProcess()) + .withVariables(request.getVariables()) + .withCategory(request.getCategory() == null ? null : EmailCategory.valueOf(request.getCategory().name())); + request.getRecordVariables().forEach((name, reference) -> mapped.withRecordVariable(name, reference.table(), reference.id())); + return (mapped); + } + + + + /******************************************************************************* + ** Map the complete create-once template contract. + *******************************************************************************/ + public static EmailTemplateDefinition template(EmailApiTemplate template) + { + EmailTemplateDefinition mapped = new EmailTemplateDefinition() + .withName(template.getName()) + .withLabel(template.getLabel()) + .withKind(template.getKind()) + .withSubject(template.getSubject()) + .withBody(template.getBody()) + .withTextBody(template.getTextBody()) + .withVariablesSchema(template.getVariablesSchema()) + .withSampleVariables(template.getSampleVariables()) + .withOwnerQbit(template.getOwnerQbit()) + .withEditPermissionName(template.getEditPermissionName()) + .withCategory(template.getCategory() == null ? null : EmailCategory.valueOf(template.getCategory().name())); + template.getVariables().forEach(variable -> mapped.withVariable(variable.name(), variable.label(), variable.type(), variable.required(), variable.classification())); + return (mapped); + } + + + + /******************************************************************************* + ** Map enqueue outcomes by their stable names. + *******************************************************************************/ + public static EmailApiResult result(EmailSendResult result) + { + return (new EmailApiResult(result.messageId(), EmailApiStatus.valueOf(result.status().name()), result.reasons())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/email/api/EmailApiBoundaryTest.java b/src/test/java/com/kingsrook/qbits/email/api/EmailApiBoundaryTest.java new file mode 100644 index 0000000..a2eeafa --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/api/EmailApiBoundaryTest.java @@ -0,0 +1,219 @@ +/* + * 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.api; + + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** No ArchUnit dependency: inspect every compiled API class and nested type, + ** including generic arguments, bounds, arrays, exceptions and inheritance. + *******************************************************************************/ +class EmailApiBoundaryTest +{ + /******************************************************************************* + ** Discovery uses production output so new API classes cannot escape review. + *******************************************************************************/ + @Test + void publishedSignaturesUseOnlyApiJdkAndQqqCoreTypes() throws Exception + { + Path root = Path.of(EmailApi.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Path packagePath = root.resolve("com/kingsrook/qbits/email/api"); + int count = 0; + try(var files = Files.walk(packagePath)) + { + for(Path path : files.filter(p -> p.toString().endsWith(".class")).toList()) + { + String name = root.relativize(path).toString().replace('\\', '.').replace('/', '.').replaceFirst("\\.class$", ""); + inspect(Class.forName(name)); + count++; + } + } + assertThat(count).isGreaterThanOrEqualTo(11); + } + + + + /******************************************************************************* + ** Prove the guard catches indirect leaks, not just raw return classes. + *******************************************************************************/ + @Test + void rejectsNestedGenericAndProtectedLeaks() throws Exception + { + assertThatThrownBy(() -> inspect(LeakingGeneric.class)).isInstanceOf(AssertionError.class); + assertThatThrownBy(() -> inspect(LeakingProtected.class)).isInstanceOf(AssertionError.class); + } + + + + /******************************************************************************* + ** Constructors, fields, inherited methods and protected declarations count. + *******************************************************************************/ + private void inspect(Class apiClass) + { + Set seen = new HashSet<>(); + check(apiClass.getGenericSuperclass(), seen); + checkAll(apiClass.getGenericInterfaces(), seen); + checkAll(apiClass.getTypeParameters(), seen); + for(var field : apiClass.getDeclaredFields()) + { + if(visible(field.getModifiers())) + { + check(field.getGenericType(), seen); + } + } + for(var constructor : apiClass.getDeclaredConstructors()) + { + if(visible(constructor.getModifiers())) + { + checkAll(constructor.getGenericParameterTypes(), seen); + checkAll(constructor.getGenericExceptionTypes(), seen); + checkAll(constructor.getTypeParameters(), seen); + } + } + Set methods = new HashSet<>(java.util.List.of(apiClass.getDeclaredMethods())); + methods.addAll(java.util.List.of(apiClass.getMethods())); + for(var method : methods) + { + if(visible(method.getModifiers())) + { + check(method.getGenericReturnType(), seen); + checkAll(method.getGenericParameterTypes(), seen); + checkAll(method.getGenericExceptionTypes(), seen); + checkAll(method.getTypeParameters(), seen); + } + } + } + + + + /******************************************************************************* + ** Public and protected form the published contract. + *******************************************************************************/ + private boolean visible(int modifiers) + { + return (Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers)); + } + + + + /******************************************************************************* + ** Walk each type without looping on recursive type bounds. + *******************************************************************************/ + private void checkAll(Type[] types, Set seen) + { + for(Type type : types) + { + check(type, seen); + } + } + + + + /******************************************************************************* + ** Generic containers cannot hide an implementation or third-party type. + *******************************************************************************/ + private void check(Type type, Set seen) + { + if(type == null || !seen.add(type)) + { + return; + } + if(type instanceof Class clazz) + { + if(clazz.isArray()) + { + check(clazz.getComponentType(), seen); + } + else + { + String name = clazz.getName(); + String module = clazz.getModule().getName(); + boolean jdk = module != null && (module.startsWith("java.") || module.startsWith("jdk.")); + assertThat(clazz.isPrimitive() || jdk + || name.startsWith("com.kingsrook.qqq.backend.core.") || name.startsWith("com.kingsrook.qbits.email.api.")) + .as("Published signature type %s", name).isTrue(); + } + } + else if(type instanceof ParameterizedType parameterized) + { + check(parameterized.getRawType(), seen); + check(parameterized.getOwnerType(), seen); + checkAll(parameterized.getActualTypeArguments(), seen); + } + else if(type instanceof TypeVariable variable) + { + checkAll(variable.getBounds(), seen); + } + else if(type instanceof WildcardType wildcard) + { + checkAll(wildcard.getUpperBounds(), seen); + checkAll(wildcard.getLowerBounds(), seen); + } + else if(type instanceof GenericArrayType array) + { + check(array.getGenericComponentType(), seen); + } + else + { + throw (new AssertionError("Unhandled signature type: " + type)); + } + } + + + + /******************************************************************************* + ** Deliberately invalid fixture, outside production class discovery. + *******************************************************************************/ + private static class LeakingGeneric + { + /******************************************************************************* + ** A wildcard and array must not disguise an internal request type. + *******************************************************************************/ + public java.util.List leak() + { + return (null); + } + } + + + + /******************************************************************************* + ** Protected fields are also part of the API contract. + *******************************************************************************/ + private static class LeakingProtected + { + protected com.kingsrook.qbits.email.EmailQBitConfig config; + } +} diff --git a/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java new file mode 100644 index 0000000..308ed60 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java @@ -0,0 +1,401 @@ +/* + * 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.api; + + +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.email.EmailQBitConfig; +import com.kingsrook.qbits.email.EmailRuntime; +import com.kingsrook.qbits.email.H2Support; +import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qbits.email.outbox.EmailCategory; +import com.kingsrook.qbits.email.outbox.EmailRequest; +import com.kingsrook.qbits.email.outbox.EmailSendResult; +import com.kingsrook.qbits.email.support.EmailApiMapper; +import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Stable API mapping and behavior against the real outbox and H2 database. + *******************************************************************************/ +class EmailApiTest +{ + private QInstance instance; + private QRecord provider; + + + + /******************************************************************************* + ** No environment secrets are used by these tests. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + instance = H2Support.setup(config -> config.withSecretResolver(name -> null)); + provider = H2Support.provider(25); + } + + + + /******************************************************************************* + ** Release thread-local context. + *******************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** Every request property reaches the internal request with the same value. + *******************************************************************************/ + @Test + void mapsEveryRequestField() throws Exception + { + Instant after = Instant.parse("2030-01-02T03:04:05Z"); + EmailApiRequest request = new EmailApiRequest().withTo(new ArrayList<>(List.of("to@example.com"))).withTo("second@example.com") + .withCc(new ArrayList<>(List.of("cc@example.com"))).withCc("cc2@example.com") + .withBcc(new ArrayList<>(List.of("bcc@example.com"))).withBcc("bcc2@example.com") + .withReplyTo("reply@example.com").withFrom("from@example.com").withFromAddressId(8) + .withSubject("Subject").withMarkdownBody("Markdown").withHtmlBody("

HTML

").withTextBody("Text") + .withTemplateName("notice").withVariables(new java.util.LinkedHashMap<>(Map.of("number", 7))) + .withVariable("label", "value").withRecordVariables(new java.util.LinkedHashMap<>()) + .withRecordVariable("order", "orders", 42).withCategory(EmailApiCategory.SECURITY) + .withProviderName("named").withBrandName("brand").withLocale("fr").withIdempotencyKey("api:42") + .withLinkedTableName("old").withLinkedRecordId("old").withLinkedRecord("orders", 42) + .withSenderQbit("old").withSenderProcess("old").withSender("orders", "complete").withSendAfter(after); + EmailRequest mapped = EmailApiMapper.request(request); + for(Method getter : EmailApiRequest.class.getDeclaredMethods()) + { + if(!getter.getName().startsWith("get")) + { + continue; + } + Object value = getter.invoke(request); + assertThat(value).as(getter.getName()).isNotNull(); + if(getter.getName().equals("getCategory")) + { + assertThat(mapped.getCategory().name()).isEqualTo(request.getCategory().name()); + } + else if(getter.getName().equals("getRecordVariables")) + { + assertThat(mapped.getRecordVariables()).hasSize(1); + assertThat(mapped.getRecordVariables().get("order").table()).isEqualTo("orders"); + assertThat(mapped.getRecordVariables().get("order").id()).isEqualTo(42); + } + else + { + assertThat(EmailRequest.class.getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(value); + } + } + assertThat(mapped.getTo()).containsExactly("to@example.com", "second@example.com"); + assertThat(mapped.getLinkedRecordId()).isEqualTo("42"); + assertThat(mapped.getSenderQbit()).isEqualTo("orders"); + assertThat(new EmailApiRequest().withLinkedRecord("orders", null).getLinkedRecordId()).isNull(); + assertThat(EmailApiMapper.request(new EmailApiRequest()).getCategory()).isNull(); + assertThat(EmailApiMapper.request(new EmailApiRequest()).getLocale()).isEqualTo("en"); + for(EmailApiCategory category : EmailApiCategory.values()) + { + assertThat(EmailApiMapper.request(new EmailApiRequest().withCategory(category)).getCategory()).isEqualTo(EmailCategory.valueOf(category.name())); + } + } + + + + /******************************************************************************* + ** All statuses and refusal reasons survive mapping without sharing lists. + *******************************************************************************/ + @Test + void mapsEveryStatus() + { + for(EmailSendResult.Status status : EmailSendResult.Status.values()) + { + List reasons = new ArrayList<>(List.of("Reason")); + EmailApiResult result = EmailApiMapper.result(new EmailSendResult(42, status, reasons)); + reasons.clear(); + assertThat(result.messageId()).isEqualTo(42); + assertThat(result.status()).isEqualTo(EmailApiStatus.valueOf(status.name())); + assertThat(result.reasons()).containsExactly("Reason"); + assertThatThrownBy(() -> result.reasons().clear()).isInstanceOf(UnsupportedOperationException.class); + } + } + + + + /******************************************************************************* + ** Sends keep validation, idempotency, suppression and permission behavior. + *******************************************************************************/ + @Test + void sendsThroughExistingService() throws Exception + { + EmailApiResult queued = EmailApi.send(request("same")); + assertThat(queued.status()).isEqualTo(EmailApiStatus.QUEUED); + assertThat(EmailApi.send(request("same")).status()).isEqualTo(EmailApiStatus.DUPLICATE); + assertThat(EmailApi.send(request("same")).messageId()).isEqualTo(queued.messageId()); + assertThat(EmailApi.send(null).status()).isEqualTo(EmailApiStatus.REFUSED); + assertThat(EmailApi.send(request("bad").withSubject("bad\nsubject")).status()).isEqualTo(EmailApiStatus.REFUSED); + SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com") + .withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL")); + assertThat(EmailApi.send(request("suppressed")).status()).isEqualTo(EmailApiStatus.SUPPRESSED); + instance.setEnforcePermissionsInCoreActions(true); + QContext.getQSession().setPermissions(Set.of()); + assertThat(EmailApi.send(request("denied")).reasons()).contains("Sending email needs email.send"); + assertThat(EmailApi.getMessage(queued.messageId())).isPresent(); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("same", 1)).hasSize(1); + } + + + + /******************************************************************************* + ** Plain sends need only QQQ core messaging types at the consumer boundary. + *******************************************************************************/ + @Test + void coreOnlyBridgeQueuesPlainMail() throws Exception + { + new SendMessageAction().execute(new SendMessageInput().withMessagingProviderName("qbitEmail") + .withTo(new Party().withAddress("pat@example.com")).withSubject("Ready") + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("Export ready"))); + assertThat(H2Support.all(EmailTables.MESSAGE)).singleElement().satisfies(row -> + { + assertThat(row.getValueString("subject")).isEqualTo("Ready"); + assertThat(row.getValueString("status")).isEqualTo("QUEUED"); + }); + } + + + + /******************************************************************************* + ** Template fields are preserved, creation is idempotent, and edits survive. + *******************************************************************************/ + @Test + void ensuresAndMapsTemplate() throws Exception + { + EmailApiTemplate template = template().withLabel("Notice").withKind("HTML").withTextBody("Text {{number}}") + .withVariables(List.of(new EmailApiVariable("number", "Number", "STRING", true, "PUBLIC"))) + .withSampleVariables("{\"number\":\"42\"}").withOwnerQbit("orders").withEditPermissionName("orders.edit"); + var mapped = EmailApiMapper.template(template); + for(Method getter : EmailApiTemplate.class.getDeclaredMethods()) + { + if(getter.getName().startsWith("get") && !Set.of("getVariables", "getVariablesSchema", "getCategory").contains(getter.getName())) + { + assertThat(mapped.getClass().getMethod(getter.getName()).invoke(mapped)).as(getter.getName()).isEqualTo(getter.invoke(template)); + } + } + assertThat(mapped.getCategory()).isEqualTo(EmailCategory.TRANSACTIONAL); + assertThat(mapped.getVariablesSchema()).contains("number", "Number", "STRING", "true", "PUBLIC"); + assertThat(EmailApi.ensureTemplate(template)).isTrue(); + assertThat(EmailApi.ensureTemplate(template.withBody("Replacement"))).isFalse(); + assertThat(H2Support.all(EmailTables.TEMPLATE)).singleElement().satisfies(row -> + { + assertThat(row.getValueString("ownerQbit")).isEqualTo("orders"); + assertThat(row.getValueString("editPermissionName")).isEqualTo("orders.edit"); + assertThat(row.getValueBoolean("phiApproved")).isFalse(); + }); + assertThat(H2Support.all(EmailTables.VERSION)).singleElement().satisfies(row -> + { + assertThat(row.getValueString("bodySource")).isEqualTo("Hello {{number}}"); + assertThat(row.getValueString("textSource")).isEqualTo("Text {{number}}"); + assertThat(row.getValueString("status")).isEqualTo("ACTIVE"); + }); + assertThat(EmailApi.send(new EmailApiRequest().withTo("pat@example.com").withTemplateName("notice").withVariable("number", "42")).status()) + .isEqualTo(EmailApiStatus.QUEUED); + assertThat(EmailApi.ensureTemplate(template().withName("raw_schema").withVariablesSchema("[]").withBody("Body"))).isTrue(); + assertThatThrownBy(() -> EmailApi.ensureTemplate(new EmailApiTemplate())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withVariable("number", "Number", "STRING", true, "PUBLIC") + .withVariablesSchema("[]"))).hasMessageContaining("not both"); + assertThatThrownBy(() -> EmailApi.ensureTemplate(template().withName("private_notice") + .withVariable(new EmailApiVariable("number", "Number", "STRING", true, "PHI")) + .withSampleVariables("{\"number\":\"synthetic\"}"))).hasMessageContaining("PHI"); + } + + + + /******************************************************************************* + ** Lookups expose only metadata and handle literal wildcard characters. + *******************************************************************************/ + @Test + void looksUpMetadataAndLiteralPrefixes() throws Exception + { + EmailApi.ensureTemplate(template().withBody("Body")); + int templateId = H2Support.all(EmailTables.TEMPLATE).getFirst().getValueInteger("id"); + Instant sent = Instant.parse("2030-01-02T03:04:05Z"); + Instant delivered = sent.plusSeconds(5); + EmailApiResult result = EmailApi.send(request("orders:1").withLinkedRecord("orders", 9)); + SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", result.messageId()).withValue("templateId", templateId) + .withValue("status", "DELIVERED").withValue("providerMessageId", "provider-42").withValue("sentDate", sent).withValue("deliveredDate", delivered)); + EmailApiMessage message = EmailApi.getMessage(result.messageId()).orElseThrow(); + assertThat(message.id()).isEqualTo(result.messageId()); + assertThat(message.status()).isEqualTo("DELIVERED"); + assertThat(message.category()).isEqualTo(EmailApiCategory.TRANSACTIONAL); + assertThat(message.idempotencyKey()).isEqualTo("orders:1"); + assertThat(message.providerMessageId()).isEqualTo("provider-42"); + assertThat(message.templateName()).isEqualTo("notice"); + assertThat(message.linkedTableName()).isEqualTo("orders"); + assertThat(message.linkedRecordId()).isEqualTo("9"); + assertThat(message.createDate()).isNotNull(); + assertThat(message.sentDate()).isEqualTo(sent); + assertThat(message.deliveredDate()).isEqualTo(delivered); + assertThat(EmailApi.getMessage(null)).isEmpty(); + assertThat(EmailApi.getMessage(Integer.MAX_VALUE)).isEmpty(); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("missing", 3)).isEmpty(); + EmailApi.send(request("orders:2")); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 1)).containsExactly(message); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("orders:", 10)).hasSize(2); + for(String prefix : List.of("literal_", "literal%", "literal\\")) + { + EmailApi.send(request("literalX" + prefix)); + EmailApiResult matching = EmailApi.send(request(prefix + "match")); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).extracting(EmailApiMessage::id).containsExactly(matching.messageId()); + } + for(String prefix : new String[] {null, "", " "}) + { + assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, 1)).isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", -1)).isInstanceOf(IllegalArgumentException.class); + assertThat(List.of(EmailApiMessage.class.getRecordComponents())).extracting(component -> component.getName()) + .containsExactly("id", "status", "category", "idempotencyKey", "providerMessageId", "templateName", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate"); + } + + + + /******************************************************************************* + ** Default selection follows configuration and excludes disabled senders. + *******************************************************************************/ + @Test + void selectsProviderAndEnabledSenders() throws Exception + { + assertThat(EmailApi.isInstalled(instance)).isTrue(); + assertThat(EmailApi.isInstalled(null)).isFalse(); + assertThat(EmailApi.isInstalled(new QInstance())).isFalse(); + assertThat(EmailApi.defaultProviderName(instance)).contains("relay"); + assertThat(EmailApi.defaultProviderName(null)).isEmpty(); + assertThat(EmailApi.listSenderAddresses("unknown")).isEmpty(); + assertThat(EmailApi.listSenderAddresses("relay")).isEmpty(); + QRecord first = SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id")) + .withValue("address", "first@example.com").withValue("displayName", "First").withValue("enabled", true).withValue("isDefault", true)); + SystemWrites.insertOne(EmailTables.SENDER, new QRecord().withValue("providerId", provider.getValue("id")) + .withValue("address", "disabled@example.com").withValue("enabled", false)); + assertThat(EmailApi.listSenderAddresses(null)).containsExactly(new EmailApiSenderAddress(first.getValueInteger("id"), "first@example.com", "First", true)); + EmailApiSenderAddress address = EmailApi.listSenderAddresses("relay").getFirst(); + assertThat(address.id()).isEqualTo(first.getValueInteger("id")); + assertThat(address.address()).isEqualTo("first@example.com"); + assertThat(address.displayName()).isEqualTo("First"); + assertThat(address.isDefault()).isTrue(); + EmailQBitConfig config = EmailRuntime.config(); + config.withDefaultProviderName("unknown"); + assertThat(EmailApi.defaultProviderName(instance)).isEmpty(); + config.withDefaultProviderName("relay"); + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("isDefault", false)); + assertThat(EmailApi.defaultProviderName(instance)).contains("relay"); + config.withDefaultProviderName(null); + assertThat(EmailApi.defaultProviderName(instance)).isEmpty(); + config.withDefaultProviderName("relay"); + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false)); + assertThat(EmailApi.defaultProviderName(instance)).isEmpty(); + assertThat(EmailApi.listSenderAddresses("relay")).isEmpty(); + QInstance other = H2Support.instance(H2Support.backend(), c -> c.withSecretResolver(name -> null)); + assertThatThrownBy(() -> EmailApi.defaultProviderName(other)).hasMessageContaining("current QContext"); + } + + + + /******************************************************************************* + ** Readiness reports each missing prerequisite without leaking resolver data. + *******************************************************************************/ + @Test + void reportsReadinessAndPhiPrerequisites() throws Exception + { + assertThat(EmailApi.readiness(null, null).reasons()).containsExactly("Email qbit is not installed"); + assertThat(EmailApi.readiness(instance, null).ready()).isTrue(); + EmailApiReadiness marketing = EmailApi.readiness(instance, EmailApiCategory.MARKETING); + assertThat(marketing.ready()).isFalse(); + assertThat(marketing.reasons()).hasSize(3); + assertThat(String.join(";", marketing.reasons())).contains("publicBaseUrl", "QQQ_EMAIL_UNSUBSCRIBE_KEY", "postal address"); + EmailQBitConfig config = EmailRuntime.config(); + config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> "synthetic-test-key"); + QRecord brand = SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default").withValue("isDefault", true)); + assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).singleElement().asString().contains("postal address"); + SystemWrites.updateOne(EmailTables.BRAND, new QRecord().withValue("id", brand.getValue("id")).withValue("postalAddress", "1 Main St")); + assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).ready()).isTrue(); + assertThat(EmailApi.send(request("multi").withTo("other@example.com").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.REFUSED); + assertThat(EmailApi.send(request("marketing").withCategory(EmailApiCategory.MARKETING)).status()).isEqualTo(EmailApiStatus.QUEUED); + config.withPublicBaseUrl("javascript:bad"); + assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs a valid publicBaseUrl"); + config.withPublicBaseUrl("https://app.example.com").withSecretResolver(name -> + { + throw (new IllegalStateException("DO_NOT_EXPOSE_RESOLVER_DETAIL")); + }); + assertThat(EmailApi.readiness(instance, EmailApiCategory.MARKETING).reasons()).containsExactly("MARKETING email needs QQQ_EMAIL_UNSUBSCRIBE_KEY"); + EmailApiReadiness phi = EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL, true); + assertThat(phi.reasons()).hasSize(2); + assertThat(String.join(";", phi.reasons())).contains("QQQ_EMAIL_BODY_KEY", "PHI-enabled").doesNotContain("DO_NOT_EXPOSE"); + config.withSecretResolver(name -> " "); + assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isFalse(); + config.withSecretResolver(name -> "synthetic-test-key"); + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")) + .withValue("phiAllowed", true).withValue("trackOpens", false).withValue("trackClicks", false)); + assertThat(EmailApi.readiness(instance, EmailApiCategory.SECURITY, true).ready()).isTrue(); + SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue("enabled", false)); + assertThat(EmailApi.readiness(instance, EmailApiCategory.TRANSACTIONAL).reasons()).containsExactly("An enabled named or default provider is required"); + } + + + + /******************************************************************************* + ** Small valid ad-hoc request. + *******************************************************************************/ + private EmailApiRequest request(String key) + { + return (new EmailApiRequest().withTo("pat@example.com").withSubject("Ready").withTextBody("Body").withIdempotencyKey(key)); + } + + + + /******************************************************************************* + ** Template body may be replaced by tests using a different schema. + *******************************************************************************/ + private EmailApiTemplate template() + { + return (new EmailApiTemplate().withName("notice").withLabel("Notice").withCategory(EmailApiCategory.TRANSACTIONAL) + .withSubject("Ready").withBody("Hello {{number}}")); + } +} -- 2.54.0 From acff558a9a1df76eb28b22fb57f6b19bded082ca Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 09:41:29 -0500 Subject: [PATCH 2/2] feat(api): fold EmailEffects into EmailApi EmailApi.sendOnce(request, effectKey) with EmailApiEffectOutcome and EmailApiEffectResult, lookups by idempotency key and RFC Message-ID, and rfcMessageId on EmailApiMessage, so other qbits have one public surface. EmailEffects and its types stay as internal, deprecated delegates; the durable-jobs guide, README and DESIGN 2.5 point at EmailApi. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 25 ++- docs/DESIGN.md | 21 ++- docs/DURABLE-JOBS.md | 36 ++-- .../kingsrook/qbits/email/api/EmailApi.java | 66 +++++++- .../email/api/EmailApiEffectOutcome.java | 31 ++++ .../qbits/email/api/EmailApiEffectResult.java | 30 ++++ .../qbits/email/api/EmailApiMessage.java | 2 +- .../qbits/email/api/EmailApiRequest.java | 37 ++++ .../email/outbox/EmailEffectOutcome.java | 2 + .../qbits/email/outbox/EmailEffects.java | 2 + .../qbits/email/outbox/EmailMessageRef.java | 2 + .../qbits/email/support/EmailApiMapper.java | 1 + .../qbits/email/api/EmailApiTest.java | 158 +++++++++++++++++- 14 files changed, 386 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectOutcome.java create mode 100644 src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectResult.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e06ba..d735639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] ### Added +- `EmailApi.sendOnce` as the public durable-effect handoff, with API-only outcome/result types, committed enqueue/replay semantics and suppression/transaction refusal parity. Exact idempotency-key and RFC Message-ID lookups return `EmailApiMessage`, which now includes `rfcMessageId` across all lookup routes. Public requests support ordinary caller transactions; `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internals. H2 parity, reconciliation, storage-failure and API-boundary coverage; no new dependencies. - Stable `com.kingsrook.qbits.email.api` contract for cross-qbit sends, create-once templates, metadata-only message lookups, enabled sender addresses, default provider selection and secret-safe readiness (including marketing and PHI prerequisites). JDK/QQQ-only signatures, complete request/status mapping tests and a reflection package-boundary guard; no new dependencies. - Documented both supported cross-qbit routes: core-only `SendMessageAction` via `qbitEmail`, and the public API for richer sends and lookups. - Persisted unique RFC Message-IDs, stable across delivery attempts, with SMTP/Gmail SMTP, SES raw MIME and Postmark/SendGrid/Mailgun request propagation (SES replaces the header downstream). diff --git a/README.md b/README.md index 5e19e4a..c7e5c1c 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,18 @@ EmailApiResult result = EmailApi.send(new EmailApiRequest() `EmailApi.ensureTemplate(EmailApiTemplate)` creates a template once; see [Templates from another qbit](#templates-from-another-qbit). + +For durable job effects, use `EmailApi.sendOnce(request, effectKey)`. It assigns +the effect key as the request's idempotency key and returns `EmailApiEffectResult` +with `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE` or `REFUSED` and a nullable +`EmailApiMessage`. Success means the outbox has committed; duplicate handoffs return +the same row and RFC Message-ID. Null requests, null/blank/over-500-character keys, +conflicting request keys and caller transactions are refused. Suppressed rows remain +refused on replay and include their audit metadata; refusals without a row have no +message. Database failures throw `QException` for retry or reconciliation. Ordinary +`EmailApi.send` accepts `request.withTransaction(transaction, backendName)` when a +caller-owned or cross-backend deferred enqueue is needed. See [durable job effects](docs/DURABLE-JOBS.md). + `EmailApi.isInstalled(instance)` detects whether the qbit was produced. `defaultProviderName(instance)` resolves an enabled configured/default provider; `listSenderAddresses(providerName)` lists enabled sender rows (null selects the @@ -211,12 +223,15 @@ For a PHI-approved template, use `readiness(instance, category, true)` to also c the body key and provider PHI/tracking policy. PHI is a template approval state, not a category. Readiness is a preflight, not proof that a particular send will pass. -`getMessage(id)` and `findMessagesByIdempotencyKeyPrefix(prefix, limit)` expose only -id, delivery status, category, idempotency key, provider message id, template name, +`getMessage(id)`, `getMessageByIdempotencyKey(key)`, `getMessageByRfcMessageId(rfcMessageId)` +and `findMessagesByIdempotencyKeyPrefix(prefix, limit)` expose only +id, delivery status, category, idempotency key, RFC Message-ID, provider message id, template name, linked table/id and create/sent/delivered timestamps. They never return addresses, subjects, bodies or variable snapshots. Prefixes are literal and nonblank; limits must be positive; results are ordered by ascending message id. Missing messages -return an empty Optional/list. Enqueue results have `QUEUED`, `DUPLICATE`, +return an empty Optional/list. Exact-key lookups also return empty for null/blank +values; RFC lookup takes the stored bracketed header, not a provider response id. +Ordinary enqueue results have `QUEUED`, `DUPLICATE`, `SUPPRESSED` or `REFUSED`; delivery status is a separate string on `EmailApiMessage`. Database calls require the caller's initialized `QContext`; methods accepting an @@ -229,7 +244,9 @@ and lookup arguments throw `IllegalArgumentException`. No secret values are expo The API uses only JDK, QQQ core and its own API types in published signatures. Changes within a `0.x` minor release line are additive only. Internal packages (including runtime, configuration, outbox, metadata, support and template classes) -are not API and may change. The internal service details below describe host and +are not API and may change. `EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` +are deprecated internals; `EmailApi` is the sole public qbit-email surface. +The internal service details below describe host and qbit-email implementation behavior, not additional cross-qbit contracts. ## Internal sending service diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d3f6fdf..f328508 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -101,7 +101,7 @@ template may set `edit_permission_name` to restrict who edits it (e.g. marketing ### 2.5 Public API -`com.kingsrook.qbits.email.api` is the stable Java contract for consuming qbits. +`com.kingsrook.qbits.email.api.EmailApi` is the sole public qbit-email surface for consuming qbits. Changes within a `0.x` minor release line are additive only. Public/protected signatures contain only JDK, QQQ core or API-package types; internal packages are not API and may change. A reflection test discovers every compiled API class and @@ -111,13 +111,26 @@ checks signatures, including generic arguments, bounds and nested types. permissions, schema/PHI validation, suppression and one-recipient rules. Requests cover recipients/sender selection, body alternatives, template variables and record provenance, category/provider/brand/locale, idempotency, linked records, producer - identity and scheduling. Results expose message id, enqueue status and reasons. + identity, scheduling and caller transactions (`withTransaction(transaction, backendName)`). + Results expose message id, enqueue status and reasons. +- `sendOnce(EmailApiRequest, effectKey)` provides durable handoff through the existing + internal effects implementation. The key becomes the request's idempotency key; + null requests, missing/blank/over-500-character keys, mismatches and caller transactions + are refused. `EmailApiEffectResult` carries `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE` + or `REFUSED` and nullable `EmailApiMessage`. Success means committed outbox storage, + and replays retain the row and RFC Message-ID. Suppressed audit rows remain refused + on replay and return metadata; refusals without a row return null. Operational + failures throw `QException`. The deprecated `EmailEffects`, `EmailEffectOutcome` and + `EmailMessageRef` remain internal; consumers use this API. See [durable jobs](DURABLE-JOBS.md). - `ensureTemplate(EmailApiTemplate)` delegates create-once template registration; `EmailApiVariable` carries the variable contract. Existing content is never overwritten, and the API does not confer PHI approval. -- `getMessage` and bounded, literal `findMessagesByIdempotencyKeyPrefix` perform +- `getMessage`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and + bounded, literal `findMessagesByIdempotencyKeyPrefix` perform system reads of an explicit metadata projection: id/status/category, idempotency - and provider ids, template name, linked record and create/sent/delivered dates. + key, RFC Message-ID and provider id, template name, linked record and create/sent/delivered dates. + Exact-key lookups return empty for null/blank/missing values. RFC lookup requires + the exact bracketed header stored at enqueue time, distinct from provider response ids. No recipients, subjects, bodies or snapshots are fetched or returned by lookups. Callers own authorization before presenting metadata to users. - `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace diff --git a/docs/DURABLE-JOBS.md b/docs/DURABLE-JOBS.md index 8311c9f..ee42f04 100644 --- a/docs/DURABLE-JOBS.md +++ b/docs/DURABLE-JOBS.md @@ -50,16 +50,18 @@ and [SES header replacement](https://docs.aws.amazon.com/ses/latest/APIReference ## Handoff and reconcile -`EmailEffects.send(request, effectIdempotencyKey)` sets the request's key, refusing -a different pre-existing key, a missing/blank/overlength effect key, a null request +`EmailApi.sendOnce(EmailApiRequest request, String effectKey)` sets the request's key, refusing +a different pre-existing key, a missing/blank/over-500-character effect key, a null request or a caller transaction. The service performs its usual validation and send permission checks. Success is returned only after the outbox transaction commits; -there is no transport call here. Use ordinary `EmailService.send` for caller-owned -or cross-backend deferred transactions. +there is no transport call here. Use ordinary `EmailApi.send` with +`request.withTransaction(transaction, backendName)` for caller-owned or cross-backend +deferred transactions. `sendOnce` refuses either kind of caller transaction. -`EmailEffects.Result(outcome, message)` contains an `EmailEffectOutcome` and a -nullable `EmailMessageRef(Integer id, String status, String rfcMessageId, -String providerMessageId)`: +`EmailApiEffectResult(outcome, message)` contains an `EmailApiEffectOutcome` and a +nullable `EmailApiMessage` with the outbox id, current delivery status, RFC Message-ID, +provider reference and other metadata. All these types are in +`com.kingsrook.qbits.email.api`, the sole public qbit-email surface: - `QUEUED_OR_DUPLICATE`: one committed row accepted the handoff, including replays. - `REFUSED`: invalid or suppressed request. A suppressed audit row has a reference; @@ -68,11 +70,15 @@ String providerMessageId)`: - Database/query failures throw `QException`; callers must retry or reconcile, never turn an uncertain outcome into a permanent refusal. -`findByIdempotencyKey(key)` and `findByRfcMessageId(messageId)` return -`Optional` from system reads, like other internal outbox reads. +`EmailApi.getMessageByIdempotencyKey(key)` and `EmailApi.getMessageByRfcMessageId(messageId)` +return `Optional` from system reads of an explicit metadata projection. They return empty for null/blank/missing values. These are trusted backend APIs, not viewer-scoped public endpoints. Lookups reflect current status and provider -reference and do not expose bodies, recipients or variables. +reference and do not expose bodies, recipients or variables. `EmailApi.getMessage(id)` +and `EmailApi.findMessagesByIdempotencyKeyPrefix(prefix, limit)` return the same projection, +including `rfcMessageId`. Callers must authorize exposing this metadata to their users. +`EmailEffects`, `EmailEffectOutcome` and `EmailMessageRef` are deprecated internal +implementation types; consuming qbits use `EmailApi`. The effect's `SENT` means successful durable handoff, not mailbox delivery. The email dispatcher owns later delivery/retry failures. Reconcile acknowledges an @@ -83,7 +89,7 @@ at least the maximum effect retry/reconciliation horizon. ## RC.7 binding design note (not compiled) Mapping: `deliver -> QUEUED_OR_DUPLICATE => SENT(ref.id)`, `REFUSED => FAILED`; -`reconcile -> findByIdempotencyKey present => FOUND(ref.id), absent => NOT_FOUND`. +`reconcile -> getMessageByIdempotencyKey present => FOUND(ref.id), absent => NOT_FOUND`. Exceptions escape to core's retry/unknown handling. Payload decoding must preserve the existing PHI/variable policies and must not attach a caller transaction. @@ -98,9 +104,9 @@ final class EmailJobEffectTarget implements EffectTarget @Override public DeliveryResult deliver(Effect effect) throws Exception { - EmailRequest request = decode(effect.payload()); - EmailEffects.Result result = EmailEffects.send(request, effect.idempotencyKey()); - if(result.outcome() == EmailEffectOutcome.REFUSED) + EmailApiRequest request = decode(effect.payload()); + EmailApiEffectResult result = EmailApi.sendOnce(request, effect.idempotencyKey()); + if(result.outcome() == EmailApiEffectOutcome.REFUSED) { return (DeliveryResult.failed("Email refused")); } @@ -111,7 +117,7 @@ final class EmailJobEffectTarget implements EffectTarget @Override public ReconcileResult reconcile(Effect effect) throws Exception { - return (EmailEffects.findByIdempotencyKey(effect.idempotencyKey()) + return (EmailApi.getMessageByIdempotencyKey(effect.idempotencyKey()) .map(ref -> ReconcileResult.found(ref.id().toString())) .orElseGet(ReconcileResult::notFound)); } diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java index d5878a0..8e7e630 100644 --- a/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java @@ -29,6 +29,7 @@ import java.util.function.Function; import com.kingsrook.qbits.email.EmailRuntime; import com.kingsrook.qbits.email.metadata.EmailTableCustomizers; import com.kingsrook.qbits.email.metadata.EmailTables; +import com.kingsrook.qbits.email.outbox.EmailEffects; import com.kingsrook.qbits.email.outbox.EmailService; import com.kingsrook.qbits.email.support.EmailApiMapper; import com.kingsrook.qbits.email.support.EmailStore; @@ -58,7 +59,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance; *******************************************************************************/ public final class EmailApi { - private static final Set MESSAGE_FIELDS = Set.of("id", "status", "category", "idempotencyKey", "providerMessageId", + private static final Set MESSAGE_FIELDS = Set.of("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId", "templateId", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate"); @@ -94,6 +95,30 @@ public final class EmailApi + /******************************************************************************* + ** Commit one durable handoff with the effect key as the idempotency key. + ** Refuse null requests, blank or over-500-character keys, conflicting keys + ** and caller transactions before changing the request. Suppression is REFUSED, + ** including replays; a retained audit row is returned. Success acknowledges + ** committed storage, not delivery. Database failures propagate as QException. + *******************************************************************************/ + public static EmailApiEffectResult sendOnce(EmailApiRequest request, String effectKey) throws QException + { + if(request == null || effectKey == null || effectKey.isBlank() || effectKey.length() > 500 + || request.getTransaction() != null + || request.getIdempotencyKey() != null && !request.getIdempotencyKey().equals(effectKey)) + { + return (new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + } + request.withIdempotencyKey(effectKey); + var result = EmailEffects.send(EmailApiMapper.request(request), effectKey); + EmailApiMessage message = result.message() == null ? null : getMessage(result.message().id()) + .orElseThrow(() -> new QException("Queued email reference is unavailable")); + return (new EmailApiEffectResult(EmailApiEffectOutcome.valueOf(result.outcome().name()), message)); + } + + + /******************************************************************************* ** Create a validated template and active version once. Returns false when ** it already exists. Invalid definitions throw IllegalArgumentException. @@ -121,6 +146,43 @@ public final class EmailApi + /******************************************************************************* + ** System lookup by exact effect or application key. Null, blank and unknown + ** keys return empty. Returns current metadata without message content. + *******************************************************************************/ + public static Optional getMessageByIdempotencyKey(String key) throws QException + { + return (findMessage("idempotencyKey", key)); + } + + + + /******************************************************************************* + ** System lookup by the exact bracketed RFC Message-ID stored at enqueue time. + ** Null, blank and unknown ids return empty; provider response ids are distinct. + *******************************************************************************/ + public static Optional getMessageByRfcMessageId(String rfcMessageId) throws QException + { + return (findMessage("rfcMessageId", rfcMessageId)); + } + + + + /******************************************************************************* + ** Share exact-key lookup semantics and the metadata-only projection. + *******************************************************************************/ + private static Optional findMessage(String field, String value) throws QException + { + if(value == null || value.isBlank()) + { + return (Optional.empty()); + } + List rows = read(EmailTables.MESSAGE, EmailStore.equal(field, value).withLimit(1), MESSAGE_FIELDS); + return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst()))); + } + + + /******************************************************************************* ** System lookup by literal prefix, ordered by ascending message id. Prefix ** must be nonblank and limit positive; invalid arguments are rejected. @@ -352,7 +414,7 @@ public final class EmailApi } String category = row.getValueString("category"); return (new EmailApiMessage(row.getValueInteger("id"), row.getValueString("status"), category == null ? null : EmailApiCategory.valueOf(category), - row.getValueString("idempotencyKey"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"), + row.getValueString("idempotencyKey"), row.getValueString("rfcMessageId"), row.getValueString("providerMessageId"), templateName, row.getValueString("linkedTableName"), row.getValueString("linkedRecordId"), row.getValueInstant("createDate"), row.getValueInstant("sentDate"), row.getValueInstant("deliveredDate"))); } } diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectOutcome.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectOutcome.java new file mode 100644 index 0000000..9b258e4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectOutcome.java @@ -0,0 +1,31 @@ +/* + * 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.api; + + +/******************************************************************************* + ** Durable handoff outcomes. Operational failures propagate to the caller. + *******************************************************************************/ +public enum EmailApiEffectOutcome +{ + QUEUED_OR_DUPLICATE, + REFUSED +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectResult.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectResult.java new file mode 100644 index 0000000..7004dba --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiEffectResult.java @@ -0,0 +1,30 @@ +/* + * 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.api; + + +/******************************************************************************* + ** Durable handoff result. The message is null when no outbox row was retained. + ** Suppressed audit rows carry metadata even though the outcome is REFUSED. + *******************************************************************************/ +public record EmailApiEffectResult(EmailApiEffectOutcome outcome, EmailApiMessage message) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java index 4f1e5af..13a1e96 100644 --- a/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiMessage.java @@ -28,7 +28,7 @@ import java.time.Instant; ** Immutable delivery metadata. No addresses, subject, bodies or variable snapshots. ** Status is the persisted delivery state, distinct from EmailApiStatus enqueue outcomes. *******************************************************************************/ -public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String providerMessageId, +public record EmailApiMessage(Integer id, String status, EmailApiCategory category, String idempotencyKey, String rfcMessageId, String providerMessageId, String templateName, String linkedTableName, String linkedRecordId, Instant createDate, Instant sentDate, Instant deliveredDate) { } diff --git a/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java b/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java index 3cea3ef..4a92cd7 100644 --- a/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; /******************************************************************************* @@ -52,6 +53,8 @@ public final class EmailApiRequest private String locale = "en"; private EmailApiCategory category; private String idempotencyKey; + private QBackendTransaction transaction; + private String transactionBackendName; private Instant sendAfter; private String linkedTableName; private String linkedRecordId; @@ -398,6 +401,40 @@ public final class EmailApiRequest + /******************************************************************************* + ** Getter for the optional caller transaction. sendOnce refuses this option. + *******************************************************************************/ + public QBackendTransaction getTransaction() + { + return (transaction); + } + + + + /******************************************************************************* + ** Getter for the declared backend of the caller transaction. + *******************************************************************************/ + public String getTransactionBackendName() + { + return (transactionBackendName); + } + + + + /******************************************************************************* + ** Join a caller transaction for ordinary send; another backend defers enqueue + ** until commit. Declare its backend because QBackendTransaction does not expose + ** it. sendOnce refuses any caller transaction to guarantee committed handoff. + *******************************************************************************/ + public EmailApiRequest withTransaction(QBackendTransaction transaction, String backendName) + { + this.transaction = transaction; + this.transactionBackendName = backendName; + return (this); + } + + + /******************************************************************************* ** Getter for sendAfter. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffectOutcome.java b/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffectOutcome.java index 2fef1f3..b574196 100644 --- a/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffectOutcome.java +++ b/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffectOutcome.java @@ -22,8 +22,10 @@ package com.kingsrook.qbits.email.outbox; /******************************************************************************* + ** Internal; use EmailApi. ** Durable handoff outcomes. Operational failures propagate to the caller. *******************************************************************************/ +@Deprecated(forRemoval = false) public enum EmailEffectOutcome { QUEUED_OR_DUPLICATE, diff --git a/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffects.java b/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffects.java index 0b02e6d..6564a2f 100644 --- a/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffects.java +++ b/src/main/java/com/kingsrook/qbits/email/outbox/EmailEffects.java @@ -31,10 +31,12 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Internal; use EmailApi. ** Outbox handoff and system-read reconciliation for trusted effect dispatchers. ** This API has no dependency on core durable-job types. Sending retains the ** EmailService permission checks; callers must supply a request without a transaction. *******************************************************************************/ +@Deprecated(forRemoval = false) public final class EmailEffects { /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/email/outbox/EmailMessageRef.java b/src/main/java/com/kingsrook/qbits/email/outbox/EmailMessageRef.java index 71e70bd..960628c 100644 --- a/src/main/java/com/kingsrook/qbits/email/outbox/EmailMessageRef.java +++ b/src/main/java/com/kingsrook/qbits/email/outbox/EmailMessageRef.java @@ -22,8 +22,10 @@ package com.kingsrook.qbits.email.outbox; /******************************************************************************* + ** Internal; use EmailApi. ** Reconciliation reference without message bodies, recipients or credentials. *******************************************************************************/ +@Deprecated(forRemoval = false) public record EmailMessageRef(Integer id, String status, String rfcMessageId, String providerMessageId) { } diff --git a/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java b/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java index 55fef89..7334712 100644 --- a/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java +++ b/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java @@ -70,6 +70,7 @@ public final class EmailApiMapper .withBrandName(request.getBrandName()) .withLocale(request.getLocale()) .withIdempotencyKey(request.getIdempotencyKey()) + .withTransaction(request.getTransaction(), request.getTransactionBackendName()) .withSendAfter(request.getSendAfter()) .withLinkedTableName(request.getLinkedTableName()) .withLinkedRecordId(request.getLinkedRecordId()) diff --git a/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java index 308ed60..b927900 100644 --- a/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java +++ b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java @@ -32,16 +32,20 @@ import com.kingsrook.qbits.email.EmailRuntime; import com.kingsrook.qbits.email.H2Support; import com.kingsrook.qbits.email.metadata.EmailTables; import com.kingsrook.qbits.email.outbox.EmailCategory; +import com.kingsrook.qbits.email.outbox.EmailEffects; import com.kingsrook.qbits.email.outbox.EmailRequest; import com.kingsrook.qbits.email.outbox.EmailSendResult; import com.kingsrook.qbits.email.support.EmailApiMapper; import com.kingsrook.qbits.email.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import org.junit.jupiter.api.AfterEach; @@ -101,7 +105,8 @@ class EmailApiTest .withRecordVariable("order", "orders", 42).withCategory(EmailApiCategory.SECURITY) .withProviderName("named").withBrandName("brand").withLocale("fr").withIdempotencyKey("api:42") .withLinkedTableName("old").withLinkedRecordId("old").withLinkedRecord("orders", 42) - .withSenderQbit("old").withSenderProcess("old").withSender("orders", "complete").withSendAfter(after); + .withSenderQbit("old").withSenderProcess("old").withSender("orders", "complete").withSendAfter(after) + .withTransaction(new QBackendTransaction(), "rdbms"); EmailRequest mapped = EmailApiMapper.request(request); for(Method getter : EmailApiRequest.class.getDeclaredMethods()) { @@ -184,6 +189,154 @@ class EmailApiTest + /******************************************************************************* + ** Durable handoffs and internal replays agree on the committed row and header. + *******************************************************************************/ + @Test + void sendsOnceAndReconcilesCurrentMetadata() throws Exception + { + String key = "job:2:row:email_%"; + EmailApiRequest request = request(null).withLinkedRecord("orders", 42); + EmailApiEffectResult first = EmailApi.sendOnce(request, key); + assertThat(request.getIdempotencyKey()).isEqualTo(key); + assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE); + assertThat(first.message().status()).isEqualTo("QUEUED"); + assertThat(first.message().idempotencyKey()).isEqualTo(key); + assertThat(first.message().rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>"); + assertThat(EmailApi.sendOnce(request(key), key)).isEqualTo(first); + var internal = EmailEffects.send(EmailApiMapper.request(request(null)), key); + assertThat(internal.outcome().name()).isEqualTo(first.outcome().name()); + assertThat(internal.message().id()).isEqualTo(first.message().id()); + assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId()); + assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1); + assertThat(EmailApi.getMessage(first.message().id())).contains(first.message()); + assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("job:", 5)).containsExactly(first.message()); + SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id()) + .withValue("status", "SENT").withValue("providerMessageId", "provider-reference")); + instance.setEnforcePermissionsInCoreActions(true); + QContext.getQSession().setPermissions(Set.of()); + EmailApiMessage found = EmailApi.getMessageByIdempotencyKey(key).orElseThrow(); + assertThat(found.id()).isEqualTo(first.message().id()); + assertThat(found.status()).isEqualTo("SENT"); + assertThat(found.providerMessageId()).isEqualTo("provider-reference"); + assertThat(found.rfcMessageId()).isEqualTo(first.message().rfcMessageId()); + assertThat(found.linkedRecordId()).isEqualTo("42"); + assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId())).contains(found); + for(String missing : new String[] {null, "", " ", "missing", "job:", "job:2:row:email_", "provider-reference"}) + { + assertThat(EmailApi.getMessageByIdempotencyKey(missing)).isEmpty(); + assertThat(EmailApi.getMessageByRfcMessageId(missing)).isEmpty(); + } + assertThat(EmailApi.getMessageByRfcMessageId(found.rfcMessageId().replace("<", "").replace(">", ""))).isEmpty(); + } + + + + /******************************************************************************* + ** Invalid handoffs preserve caller keys and never create an outbox row. + *******************************************************************************/ + @Test + void sendOnceRefusesInvalidRequestsAndKeys() throws Exception + { + for(String key : new String[] {null, "", " ", "a".repeat(501)}) + { + EmailApiRequest request = request(null); + assertThat(EmailApi.sendOnce(request, key)).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + assertThat(request.getIdempotencyKey()).isNull(); + assertThat(EmailEffects.send(EmailApiMapper.request(request), key).outcome().name()).isEqualTo("REFUSED"); + } + EmailApiRequest conflict = request("other"); + assertThat(EmailApi.sendOnce(conflict, "effect").outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED); + assertThat(conflict.getIdempotencyKey()).isEqualTo("other"); + assertThat(EmailApi.sendOnce(null, "effect")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + assertThat(EmailApi.sendOnce(request(null).withSubject(null), "invalid")) + .isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty(); + assertThat(EmailApi.sendOnce(request(null), "a".repeat(500)).outcome()).isEqualTo(EmailApiEffectOutcome.QUEUED_OR_DUPLICATE); + instance.setEnforcePermissionsInCoreActions(true); + QContext.getQSession().setPermissions(Set.of()); + assertThat(EmailApi.sendOnce(request(null), "denied")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1); + } + + + + /******************************************************************************* + ** Replaying a suppressed audit row remains refused with the same identity. + *******************************************************************************/ + @Test + void sendOnceRefusesSuppressedDuplicates() throws Exception + { + SystemWrites.insertOne(EmailTables.SUPPRESSION, new QRecord().withValue("address", "pat@example.com") + .withValue("reason", "HARD_BOUNCE").withValue("scope", "ALL")); + EmailApiEffectResult first = EmailApi.sendOnce(request(null), "suppressed"); + assertThat(first.outcome()).isEqualTo(EmailApiEffectOutcome.REFUSED); + assertThat(first.message().status()).isEqualTo("SUPPRESSED"); + assertThat(first.message().rfcMessageId()).isNotBlank(); + assertThat(EmailApi.sendOnce(request(null), "suppressed")).isEqualTo(first); + var internal = EmailEffects.send(EmailApiMapper.request(request(null)), "suppressed"); + assertThat(internal.outcome().name()).isEqualTo(first.outcome().name()); + assertThat(internal.message().id()).isEqualTo(first.message().id()); + assertThat(internal.message().rfcMessageId()).isEqualTo(first.message().rfcMessageId()); + assertThat(EmailApi.getMessageByIdempotencyKey("suppressed")).contains(first.message()); + assertThat(EmailApi.getMessageByRfcMessageId(first.message().rfcMessageId())).contains(first.message()); + assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1); + } + + + + /******************************************************************************* + ** Neither a shared-backend transaction nor a deferred callback is a handoff. + *******************************************************************************/ + @Test + void sendOnceRefusesCallerTransactions() throws Exception + { + try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE))) + { + for(String backend : new String[] {null, "rdbms", "other"}) + { + EmailApiRequest request = request(null).withTransaction(transaction, backend); + assertThat(EmailApi.sendOnce(request, "transaction")).isEqualTo(new EmailApiEffectResult(EmailApiEffectOutcome.REFUSED, null)); + assertThat(request.getIdempotencyKey()).isNull(); + } + transaction.commit(); + } + assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty(); + } + + + + /******************************************************************************* + ** Ordinary sends retain caller transaction behavior through the public mapper. + *******************************************************************************/ + @Test + void ordinarySendCanRollBackWithCaller() throws Exception + { + try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE))) + { + assertThat(EmailApi.send(request("rollback").withTransaction(transaction, "rdbms")).status()).isEqualTo(EmailApiStatus.QUEUED); + transaction.rollback(); + } + assertThat(EmailApi.getMessageByIdempotencyKey("rollback")).isEmpty(); + } + + + + /******************************************************************************* + ** Uncertain storage failures propagate so dispatchers can retry or reconcile. + *******************************************************************************/ + @Test + void sendOncePropagatesStorageFailure() throws Exception + { + EmailApiEffectResult first = EmailApi.sendOnce(request(null), "collision"); + SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", first.message().id()).withValue("idempotencyKey", "changed")); + assertThatThrownBy(() -> EmailApi.sendOnce(request(null), "collision")).isInstanceOf(QException.class); + assertThat(EmailApi.getMessageByIdempotencyKey("collision")).isEmpty(); + assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1); + } + + + /******************************************************************************* ** Plain sends need only QQQ core messaging types at the consumer boundary. *******************************************************************************/ @@ -266,6 +419,7 @@ class EmailApiTest assertThat(message.status()).isEqualTo("DELIVERED"); assertThat(message.category()).isEqualTo(EmailApiCategory.TRANSACTIONAL); assertThat(message.idempotencyKey()).isEqualTo("orders:1"); + assertThat(message.rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>"); assertThat(message.providerMessageId()).isEqualTo("provider-42"); assertThat(message.templateName()).isEqualTo("notice"); assertThat(message.linkedTableName()).isEqualTo("orders"); @@ -292,7 +446,7 @@ class EmailApiTest assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", 0)).isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> EmailApi.findMessagesByIdempotencyKeyPrefix("orders", -1)).isInstanceOf(IllegalArgumentException.class); assertThat(List.of(EmailApiMessage.class.getRecordComponents())).extracting(component -> component.getName()) - .containsExactly("id", "status", "category", "idempotencyKey", "providerMessageId", "templateName", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate"); + .containsExactly("id", "status", "category", "idempotencyKey", "rfcMessageId", "providerMessageId", "templateName", "linkedTableName", "linkedRecordId", "createDate", "sentDate", "deliveredDate"); } -- 2.54.0