diff --git a/CHANGELOG.md b/CHANGELOG.md index 850e7c0..d735639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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). - Core-independent `EmailEffects` handoff, reference lookups by idempotency key or RFC Message-ID, refusal mapping and an RC.7 binding design sketch; offline retry and provider-contract tests. - Checkstyle now checks test sources as well as main sources; existing test imports and formatting corrected. diff --git a/README.md b/README.md index 8ad9e26..c7e5c1c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,99 @@ 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). + +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 +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)`, `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. 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 +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. `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 ```java EmailSendResult result = EmailService.send(new EmailRequest() @@ -271,14 +363,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 +382,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 +541,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 9b68dcf..f328508 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,58 @@ 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.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 +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, 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`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and + bounded, literal `findMessagesByIdempotencyKeyPrefix` perform + system reads of an explicit metadata projection: id/status/category, idempotency + 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 + 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/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 new file mode 100644 index 0000000..8e7e630 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApi.java @@ -0,0 +1,420 @@ +/* + * 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.EmailEffects; +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", "rfcMessageId", "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)))); + } + + + + /******************************************************************************* + ** 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. + ** 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 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. + ** 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("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/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/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 new file mode 100644 index 0000000..13a1e96 --- /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 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/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..4a92cd7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/api/EmailApiRequest.java @@ -0,0 +1,670 @@ +/* + * 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; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; + + +/******************************************************************************* + ** 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 QBackendTransaction transaction; + private String transactionBackendName; + 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 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. + *******************************************************************************/ + 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/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 new file mode 100644 index 0000000..7334712 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/support/EmailApiMapper.java @@ -0,0 +1,117 @@ +/* + * 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()) + .withTransaction(request.getTransaction(), request.getTransactionBackendName()) + .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..b927900 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/api/EmailApiTest.java @@ -0,0 +1,555 @@ +/* + * 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.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; +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) + .withTransaction(new QBackendTransaction(), "rdbms"); + 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); + } + + + + /******************************************************************************* + ** 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. + *******************************************************************************/ + @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.rfcMessageId()).matches("<[0-9a-f]{32}@example\\.com>"); + 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", "rfcMessageId", "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}}")); + } +}