feat: phase (b) - Postmark, SendGrid, Mailgun and Gmail SMTP #8
No files matched your search
@@ -19,7 +19,15 @@ Design: `docs/DESIGN.md`.
|
||||
## Architecture
|
||||
|
||||
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
|
||||
- Packages are added per phase as `docs/DESIGN.md` describes.
|
||||
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
|
||||
- `outbox`: EmailService, requests/results and the replaceable ad-hoc renderer.
|
||||
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
|
||||
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
|
||||
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
|
||||
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
|
||||
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
|
||||
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
|
||||
- Templates/branding/PHI/full home rendering are part 2; their tables exist now.
|
||||
|
||||
## Invariants
|
||||
|
||||
|
||||
@@ -6,4 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Phase (b) Postmark, SendGrid and Mailgun HTTP transports with fixed HTTPS endpoints, bounded requests, tracking options, safe error classification and Retry-After support; no new dependencies or filesystem storage.
|
||||
- Strict Gmail SMTP credential/STARTTLS validation, provider-specific credential and webhook environment defaults, and the [provider setup guide](docs/PROVIDERS.md).
|
||||
- HTTP request-contract tests and local-server end-to-end outbox tests covering success, provider failures, permanent rejection, retries and secret-safe stored errors.
|
||||
- Phase (a), part 1 email tables, PostgreSQL DDL, enum sources, child relations, permissions and Email app navigation.
|
||||
- Provider validation with environment-name allowlists and presence-only credential displays.
|
||||
- Transactional ad-hoc EmailService, idempotency, suppression and a replaceable Markdown/HTML/text renderer.
|
||||
- Durable dispatcher with unique attempt claims, leases, retry/dead-letter handling, shared provider rate limits, health probes and optional alerts.
|
||||
- SMTP with authentication/TLS/timeouts, SES v2 raw MIME, and a Gmail SMTP preset.
|
||||
- QBIT_EMAIL core messaging bridge, Review/Result actions, backend send process, dispatch/maintenance/retention schedules and host integration declarations.
|
||||
- H2 concurrency and transaction tests, GreenMail end-to-end tests, fake SES tests, metadata/permission checks and process-runner coverage.
|
||||
- Repository scaffold to the QRun qbit repository standard (Apache-2.0, CircleCI qqq-orb 0.6.8 with the `configure-package-install` pre-step, GA-only `publish_release`, the `release-rc` caller workflow).
|
||||
@@ -19,7 +19,15 @@ Design: `docs/DESIGN.md`.
|
||||
## Architecture
|
||||
|
||||
- **Entry point:** `EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>`, package `com.kingsrook.qbits.email`.
|
||||
- Packages are added per phase as `docs/DESIGN.md` describes.
|
||||
- `metadata`: seven tables, enum sources, relations and provider/suppression validation.
|
||||
- `outbox`: EmailService, requests/results and the replaceable ad-hoc renderer.
|
||||
- `delivery`: durable dispatcher, attempt leases, rate limits, health and optional alerts.
|
||||
- `transport`: shared MIME, SMTP/SES transports, settings and registry.
|
||||
- `bridge`: QBIT_EMAIL adapter for core SendMessageAction.
|
||||
- `processes`: Review/Result record actions, backend send API and scheduled jobs.
|
||||
- `permissions`, `app`, `support`: custom permission checker, app/navigation and authorized database operations.
|
||||
- `EmailRuntime` resolves config from the current instance; `EmailIntegrations` declares ESB, API and Quick Search tables.
|
||||
- Templates/branding/PHI/full home rendering are part 2; their tables exist now.
|
||||
|
||||
## Invariants
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
# QBit Email
|
||||
|
||||
One email service for a QQQ system: an outbox with async sending, retries, dead letters and
|
||||
idempotency keys; providers as data rows (SMTP relay, Amazon SES, Postmark, SendGrid, Mailgun,
|
||||
Gmail); Markdown and MJML/HTML templates with versions and branding; delivery tracking from
|
||||
provider webhooks; a suppression list; RFC 8058 one-click unsubscribe; and PHI-safe sending.
|
||||
Inbound mail is phase 2.
|
||||
Phases (a) and (b): a durable email outbox for QQQ, with SMTP, Amazon SES, Postmark,
|
||||
SendGrid, Mailgun and Gmail SMTP transports,
|
||||
suppression, retries, provider health, record actions, and the core messaging bridge.
|
||||
Every send is queued in `email_message` before a dispatcher contacts a provider.
|
||||
|
||||
Design: [`docs/DESIGN.md`](docs/DESIGN.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21
|
||||
- QQQ 4.1 (`qqq-bom-pom` 4.1.0-RC.4 or later)
|
||||
- An RDBMS backend for the `email_` tables
|
||||
The approved [design](docs/DESIGN.md) covers later phases too. This implementation
|
||||
accepts ad-hoc Markdown, HTML and text. Template rendering, branding, PHI handling and
|
||||
the full home dashboard belong to part 2. Template and variable-bearing requests are
|
||||
refused in part 1; attachments are refused. Tracking webhooks and unsubscribe remain
|
||||
later phases.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Java 21, QQQ **4.1.0-RC.5**, and a transactional RDBMS with the supplied unique
|
||||
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing
|
||||
the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
|
||||
The physical table names equal their metadata names; `backendName` selects the host's
|
||||
QQQ database backend, as it does in qbit-webhooks.
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -26,27 +29,253 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md).
|
||||
|
||||
```java
|
||||
new EmailQBitProducer()
|
||||
.withQBitConfig(new EmailQBitConfig().withBackendName("rdbms"))
|
||||
.withQBitConfig(new EmailQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSchedulerName("scheduler"))
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
## Tables, permissions and configuration
|
||||
The scheduler must already exist. Without a scheduler, callers can enqueue and run
|
||||
`EmailDispatcher` or Send Now manually; automatic dispatch is not installed.
|
||||
Configuration is looked up from the current `QInstance` on every call, never cached
|
||||
in a static variable.
|
||||
|
||||
See [`docs/DESIGN.md`](docs/DESIGN.md). Tables are prefixed `email_`; permissions use the full
|
||||
qbit name (`email.view`, `email.send`, `email.manage`, `email.admin`).
|
||||
## Tables and permissions
|
||||
|
||||
## Adopting in another app
|
||||
| Table | Navigation | Writes |
|
||||
|---|---|---|
|
||||
| `email_message` | Messages | Qbit only; people use record actions |
|
||||
| `email_message_attempt` | Attempts on a message | Qbit only |
|
||||
| `email_template` | Templates | `email.manage`; rendering arrives in part 2 |
|
||||
| `email_template_version` | Versions on a template | `email.manage`; activation arrives in part 2 |
|
||||
| `email_brand` | Brands | `email.manage`; rendering arrives in part 2 |
|
||||
| `email_suppression` | Suppressions | `email.manage`; changing/removing a complaint needs `email.admin` |
|
||||
| `email_provider` | Providers, with child Messages | `email.admin`, including reads and test sends |
|
||||
|
||||
Add the dependency, produce the qbit against your RDBMS backend, add a provider row, and send
|
||||
through `EmailService` (or the `email_send_templated` process). Code that already calls core's
|
||||
`SendEmailAction` can route through the outbox with the messaging bridge (DESIGN section 2).
|
||||
`email.view` reads messages, templates, versions, brands and suppressions.
|
||||
`email.send` permits the Java service, bridge and backend send process. Reading
|
||||
messages requires `email.view`, `email.manage` or `email.admin`; send alone grants no read access.
|
||||
`email.manage` also allows sending, Resend, Cancel and Send Now. `email.admin`
|
||||
implies all permissions. Grant `email.hasAccess` for the app's derived base access.
|
||||
Enable the host's `QInstance.enforcePermissionsInCoreActions` for direct Java action
|
||||
permission enforcement; the tables and processes use the shared custom checker.
|
||||
Trusted system callers use `SystemWrites.call(...)`, which opens core's authorized
|
||||
scope and still preserves record security locks.
|
||||
|
||||
All tables and columns follow the `email_`/snake_case database convention. Java field
|
||||
names are camelCase, matching the webhooks reference. System tables disable direct
|
||||
insert/update/delete capabilities. Labels contain no status decorations.
|
||||
|
||||
## Providers and credentials
|
||||
|
||||
See the [provider setup guide](docs/PROVIDERS.md) for row fields, DNS/domain setup,
|
||||
limits, Gmail app passwords and the webhook configuration planned for phase (c).
|
||||
|
||||
| Kind | Transport | Status |
|
||||
|---|---|---|
|
||||
| SMTP | Relay or authenticated SMTP, STARTTLS / TLS | Available |
|
||||
| SES | AWS SES v2, raw MIME | Available |
|
||||
| POSTMARK | HTTPS JSON, server token and message stream | Available |
|
||||
| SENDGRID | HTTPS JSON, bearer API key | Available |
|
||||
| MAILGUN | HTTPS MIME multipart, US / EU | Available |
|
||||
| GMAIL_SMTP | smtp.gmail.com:587, STARTTLS and app password | Available |
|
||||
| GMAIL_API | OAuth Gmail API | Deferred; use GMAIL_SMTP |
|
||||
|
||||
Create the first enabled provider with `isDefault=true`. When any providers are
|
||||
enabled, exactly one must be the enabled default. Switch defaults in a batch update,
|
||||
clearing the old default first. A nullable unique `default_slot` backs the validation.
|
||||
Providers with message history cannot be deleted; disable them instead.
|
||||
|
||||
For an unauthenticated relay, use kind `SMTP`, host/port, security `NONE`, and a
|
||||
`fromAddress`. Leave credential names empty. Authenticated SMTP uses `usernameEnvName`
|
||||
and `secretEnvName`; security is `NONE`, `STARTTLS` (required) or `TLS`. Timeouts are
|
||||
10 seconds to connect and 15 seconds each to read and write. The Gmail SMTP preset
|
||||
uses `smtp.gmail.com:587` with required STARTTLS.
|
||||
|
||||
SES needs a `region`, optionally `configurationSet`. With no credential references
|
||||
it uses the AWS default chain. Otherwise both username and secret references are
|
||||
required and resolve to the access key and secret key. SES sends raw MIME with explicit
|
||||
envelope destinations, keeping BCC recipients out of the raw message headers. SDK
|
||||
retries are disabled so outbox attempts own retry accounting; an API call is bounded
|
||||
at 40 seconds.
|
||||
|
||||
Credential fields contain **environment variable names**, matching
|
||||
`^[A-Z_][A-Z0-9_]{0,99}$`. The effective allowlist is `allowedEnvNames` plus these defaults
|
||||
for the selected kind:
|
||||
|
||||
| Kind | Default allowed names |
|
||||
|---|---|
|
||||
| SMTP / GMAIL_SMTP | `EMAIL_SMTP_USERNAME`, `EMAIL_SMTP_PASSWORD`, `EMAIL_GMAIL_USERNAME`, `EMAIL_GMAIL_APP_PASSWORD`, `GMAIL_USERNAME`, `GMAIL_APP_PASSWORD` |
|
||||
| SES | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` |
|
||||
| POSTMARK | `POSTMARK_SERVER_TOKEN`, `POSTMARK_WEBHOOK_PASSWORD`; legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD` |
|
||||
| SENDGRID | `SENDGRID_API_KEY`, `SENDGRID_WEBHOOK_PUBLIC_KEY`; legacy `EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY` |
|
||||
| MAILGUN | `MAILGUN_API_KEY`, `MAILGUN_WEBHOOK_SIGNING_KEY`; legacy `EMAIL_MAILGUN_API_KEY`, `EMAIL_MAILGUN_SIGNING_KEY` |
|
||||
| GMAIL_API | `EMAIL_GMAIL_CLIENT_ID`, `EMAIL_GMAIL_CLIENT_SECRET`, `EMAIL_GMAIL_REFRESH_TOKEN` |
|
||||
|
||||
Allowlisting does not automatically select a name. Empty SMTP references mean no auth;
|
||||
empty SES references mean the default chain. Explicit SES references use a static
|
||||
access/secret pair; temporary session credentials belong in the default AWS chain.
|
||||
`withSecretResolver(Function<String,String>)` defaults to `System::getenv`. Values are
|
||||
resolved during an attempt and never retained, audited, returned or logged. The
|
||||
provider record's display-only fields return only `Set` / `Not set`; checking presence
|
||||
necessarily invokes the resolver too. Enqueue and internal queries do not resolve it.
|
||||
|
||||
POSTMARK, SENDGRID and MAILGUN require a secret environment name. Mailgun also
|
||||
requires a sending domain and US/EU region. Gmail SMTP requires both credential
|
||||
names and STARTTLS. GMAIL_API is refused with
|
||||
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
|
||||
|
||||
HTTP transports use fixed HTTPS endpoints, 10-second connect and 30-second request
|
||||
timeouts, and no redirects. HTTP 429/5xx retry with optional Retry-After; account
|
||||
and credential failures pause the provider. Tracking is explicitly controlled by
|
||||
`trackOpens` / `trackClicks`, off by default. Outbound tracking settings do not
|
||||
install the event webhook receiver; that arrives in phase (c).
|
||||
|
||||
## Sending
|
||||
|
||||
```java
|
||||
EmailSendResult result = EmailService.send(new EmailRequest()
|
||||
.withTo("Pat <pat@example.com>")
|
||||
.withCc("copy@example.com")
|
||||
.withSubject("Your export is ready")
|
||||
.withMarkdownBody("[Open your export](https://app.example.com/exports/42)")
|
||||
.withCategory(EmailCategory.NOTIFICATION)
|
||||
.withIdempotencyKey("export:42")
|
||||
.withLinkedRecord("export", 42)
|
||||
.withSender("exports", "complete"));
|
||||
```
|
||||
|
||||
Use `withHtmlBody` or `withTextBody` instead of Markdown, with optional explicit text
|
||||
alongside HTML. `withFrom`, `withReplyTo`, `withBcc`, `withProviderName`, `withSendAfter`,
|
||||
and `withLocale` are supported. `sendingDomain`, when present, must match the sender's
|
||||
domain. Addresses are parsed strictly; header newlines are refused. Markdown raw HTML
|
||||
and unsafe URL schemes are disabled. `EmailRenderer` is the replacement seam for
|
||||
part 2. Both alternatives are stored at enqueue time and reused on retries.
|
||||
|
||||
Results expose `messageId()`, `status()` (`QUEUED`, `DUPLICATE`, `SUPPRESSED`, `REFUSED`)
|
||||
and `reasons()`. Idempotency is global within the outbox. A repeated key returns the
|
||||
original id; unique constraints protect races. Idempotent inserts briefly lock the
|
||||
oldest provider row so concurrent service calls do not abort a caller-owned SQL
|
||||
transaction with a duplicate-key error.
|
||||
|
||||
`withTransaction(transaction, backendName)` joins a matching backend transaction.
|
||||
The one-argument overload assumes the email backend. A different backend registers
|
||||
an after-commit insert, discarded on rollback. Its immediate result has a null id
|
||||
and a reason explaining that enqueue is deferred: an auto-generated id does not
|
||||
exist before that callback runs. Cross-backend callbacks have the same crash window
|
||||
as qbit-webhooks' fallback; use the same backend when atomic enqueue is required.
|
||||
Without a caller transaction, the service owns and commits its transaction.
|
||||
|
||||
For multi-tenant hosts, configure `securityFields` and `recordSecurityLocks`, add the
|
||||
matching columns to the DDL, and provide the request's `securityValues`. Security
|
||||
values propagate to attempts and resends.
|
||||
|
||||
Suppression compares canonical lowercase mailboxes. ALL applies across categories,
|
||||
except UNSUBSCRIBE cannot block TRANSACTIONAL or SECURITY. Category suppressions apply
|
||||
to NOTIFICATION and MARKETING. Expired rows are ignored. Partial suppression drops
|
||||
only matching recipients and records them in `suppressedAddresses`; all-suppressed
|
||||
messages remain visible with SUPPRESSED status and never consume an attempt.
|
||||
|
||||
## Delivery and actions
|
||||
|
||||
Each claim inserts a unique `(message_id, attempt_number)` row and commits SENDING
|
||||
with a lease before network I/O. Provider-row database locks serialize rate checks
|
||||
and claims across replicas; they are released before contacting the provider. Limits
|
||||
count attempts in rolling minute and 24-hour windows. Waiting consumes no attempt.
|
||||
Per-message SMTP 5xx and SES MessageRejected/InvalidParameterValue failures become
|
||||
DEAD_LETTER, as do invalid MIME addresses or headers; transient failures
|
||||
use the configured backoff. Explicit retry-after values are capped at one hour;
|
||||
the configured backoff itself is not capped.
|
||||
|
||||
Expired attempts become LEASE_EXPIRED, then retry or dead-letter at the attempt limit.
|
||||
Late completion from an expired runner cannot overwrite the newer attempt's outcome.
|
||||
Delivery is **at least once**: provider acceptance followed by a runner crash can
|
||||
produce a repeat. Credential, authentication and SES account failures immediately mark
|
||||
the provider UNHEALTHY and stop its remaining deliveries for that dispatch pass.
|
||||
These attempts are recorded as PROVIDER_FAILURE and keep messages in RETRY_WAIT with
|
||||
backoff. They increment the historical attempt count but do not consume the message's
|
||||
maxAttempts budget (including during lease recovery). Ten consecutive other transient failures mark a provider UNHEALTHY;
|
||||
one queued message probes it every five minutes, and success restores HEALTHY.
|
||||
Optional core alerts raise `email.provider.unhealthy` and `email.message.dead-letter`,
|
||||
with dead-letter deduplication per provider/hour. No alert sink is required.
|
||||
Alert failures cannot fail an already committed delivery.
|
||||
|
||||
SMTP sign-in requires STARTTLS or TLS. Plaintext authentication is refused at provider
|
||||
validation and again before connecting, except for loopback (`localhost`, `127.0.0.1`,
|
||||
`::1`) or an explicit `withAllowPlaintextSmtpAuth(true)` host configuration (default false).
|
||||
Provider sender/reply-to fields and MIME subjects/addresses cannot contain CR or LF.
|
||||
|
||||
Messages offer standard Review/Result actions: Resend (DEAD_LETTER, BOUNCED,
|
||||
COMPLAINED, with retained content), Cancel (QUEUED/RETRY_WAIT), and Send Now
|
||||
(QUEUED/RETRY_WAIT). Resend creates a new linked message and rechecks suppression.
|
||||
Send Now overrides deferral but preserves rate limits, health and active leases.
|
||||
Providers offer Send Test Email with a recipient input and a reported outcome.
|
||||
|
||||
`email_send_templated` is a hidden backend API process. Inputs include `templateName`,
|
||||
`to`, `cc`, `bcc`, `replyTo`, `subject`, `markdownBody`, `htmlBody`, `textBody`,
|
||||
`providerName`, `brandName`, `category`, `variablesJson`, `linkedTableName`,
|
||||
`linkedRecordId`, and `idempotencyKey`. Ad-hoc content works now; template/brand/variable
|
||||
inputs return REFUSED until part 2. Output includes `messageId`, `status`, and `reasons`.
|
||||
|
||||
Hidden schedules are `email_dispatch` (15 s by default), `email_maintain` (300 s, lease
|
||||
recovery including disabled providers, and health probes), and `email_retention`
|
||||
(daily). Retention clears final-message bodies after 30 days, preserving `bodySha256`;
|
||||
message deletion is disabled by default. `finishedDate` records finalization independently
|
||||
of enqueue and later retention updates.
|
||||
|
||||
## Core messaging bridge
|
||||
|
||||
The producer adds messaging provider `qbitEmail` (configurable), type `QBIT_EMAIL`.
|
||||
Existing callers switch only `messagingProviderName`:
|
||||
|
||||
```java
|
||||
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.")));
|
||||
```
|
||||
|
||||
The bridge recursively maps Party/MultiParty TO, CC, BCC, FROM and REPLY_TO roles, plus
|
||||
TEXT/HTML alternatives. REFUSED throws `QException`; successful calls acknowledge
|
||||
queueing, not delivery. The built-in EMAIL and SES types are unchanged.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings have fluent `withX` setters and getters.
|
||||
|
||||
| Setting | Default / purpose |
|
||||
|---|---|
|
||||
| `backendName` | Required existing backend |
|
||||
| `schedulerName` | Optional existing scheduler; required for automatic dispatch |
|
||||
| `publicBaseUrl` | Reserved for later unsubscribe links |
|
||||
| `defaultProviderName` | Optional named provider override; otherwise enabled default row |
|
||||
| `bridgeProviderName` | `qbitEmail` |
|
||||
| `secretResolver`, `allowedEnvNames` | `System::getenv`, empty additional allowlist |
|
||||
| `dispatchIntervalSeconds`, `batchSize` | 15, 100 |
|
||||
| `maxAttempts`, `retryMinutes` | 6; 1, 5, 15, 60, 240, 720 |
|
||||
| `leaseSeconds` | 120; minimum 60 |
|
||||
| `bodyRetentionDays`, `messageRetentionDays` | 30, 0 (keep message rows forever) |
|
||||
| `appName`, `appLabel` | `email`, `Email` |
|
||||
| `securityFields`, `recordSecurityLocks` | Empty host additions |
|
||||
| `tableMetaDataCustomizer` | Optional final metadata customization |
|
||||
|
||||
`EmailIntegrations.esbTableNames()`, `apiTableNames()` and `quickSearchTables()` expose
|
||||
host integration declarations for messages, templates and suppressions. Quick Search
|
||||
weights exclude subjects, bodies and variable snapshots. Hosts wire their ESB/API/search
|
||||
modules to these declarations.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./mvnw verify # tests, Checkstyle and the JaCoCo gate
|
||||
./mvnw -o -q verify
|
||||
```
|
||||
|
||||
Windows can use `./mvnw.cmd -o -q verify`. No new dependencies are required. Tests use
|
||||
H2, GreenMail, a fake SES client and local HTTP servers, including transaction rollback, concurrent claims,
|
||||
concurrent idempotency, shared rate budgets, health recovery and process screens.
|
||||
See [implementation notes](docs/PART1-NOTES.md) for design/API accommodations.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,215 @@
|
||||
# Email providers
|
||||
|
||||
Create an enabled `email_provider` row and choose exactly one enabled default.
|
||||
Provider edits and Send Test Email require `email.admin`. Test sends queue through
|
||||
the outbox and obey the same rate limits as application messages.
|
||||
|
||||
Field names here are database columns. QQQ record/API names are camelCase:
|
||||
`secret_env_name` is `secretEnvName`, for example. All kinds use `name`, `label`,
|
||||
`kind`, `enabled`, `is_default`, `from_address`, optional `from_name` / `reply_to`,
|
||||
and optional `max_per_minute` / `max_per_day`. When set, `sending_domain` must
|
||||
match the From address domain exactly.
|
||||
|
||||
## Credentials and delivery
|
||||
|
||||
Credential fields contain environment variable **names**, never values. Names
|
||||
must match `^[A-Z_][A-Z0-9_]{0,99}$` and the effective allowlist. The defaults
|
||||
below supplement `EmailQBitConfig.allowedEnvNames`; explicitly allow custom names.
|
||||
Allowlisting does not automatically select a name for a row.
|
||||
|
||||
| Kind | `username_env_name` | `secret_env_name` | `webhook_secret_env_name` |
|
||||
|---|---|---|---|
|
||||
| SMTP | `EMAIL_SMTP_USERNAME` (optional) | `EMAIL_SMTP_PASSWORD` | Not used |
|
||||
| SES | `AWS_ACCESS_KEY_ID` (optional) | `AWS_SECRET_ACCESS_KEY` (optional pair) | Not used; `sns_topic_arn` is a plain ARN |
|
||||
| POSTMARK | Not used | `POSTMARK_SERVER_TOKEN` | `POSTMARK_WEBHOOK_PASSWORD` |
|
||||
| SENDGRID | Not used | `SENDGRID_API_KEY` | `SENDGRID_WEBHOOK_PUBLIC_KEY` |
|
||||
| MAILGUN | Not used | `MAILGUN_API_KEY` | `MAILGUN_WEBHOOK_SIGNING_KEY` |
|
||||
| GMAIL_SMTP | `GMAIL_USERNAME` | `GMAIL_APP_PASSWORD` | Not used |
|
||||
|
||||
Legacy `EMAIL_POSTMARK_SERVER_TOKEN`, `EMAIL_POSTMARK_WEBHOOK_PASSWORD`,
|
||||
`EMAIL_SENDGRID_API_KEY`, `EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY`, `EMAIL_MAILGUN_API_KEY`,
|
||||
`EMAIL_MAILGUN_SIGNING_KEY`, `EMAIL_GMAIL_USERNAME` and `EMAIL_GMAIL_APP_PASSWORD`
|
||||
remain allowlisted for their kinds. SES also allowlists `AWS_SESSION_TOKEN` for
|
||||
the default credential chain; explicit row credentials are an access/secret pair.
|
||||
|
||||
Values resolve through `secretResolver` (default `System::getenv`) for each send
|
||||
attempt. Rotation needs no service restart. Provider record presence indicators
|
||||
also resolve values, returning only Set / Not set; enqueue and internal queries
|
||||
do not resolve credentials. Values and provider response diagnostics are never
|
||||
logged, stored in errors, returned in exceptions or cached by these transports.
|
||||
|
||||
HTTP uses fixed HTTPS endpoints, a 10-second connect timeout, a 30-second request
|
||||
timeout, no redirects and no client retries. The outbox owns retries. MIME and
|
||||
multipart bodies are built entirely in memory; services use no filesystem storage.
|
||||
|
||||
HTTP 429 and 5xx are transient. Retry-After accepts seconds or an HTTP date;
|
||||
the dispatcher caps explicit delays at one hour. Authentication/account failures
|
||||
mark the provider UNHEALTHY, stop its remaining deliveries for the dispatch pass,
|
||||
and retain messages for retry without consuming their failure budget. Permanent
|
||||
message errors become DEAD_LETTER. Exceptions use fixed text and numeric codes;
|
||||
stored errors use fixed messages, never response bodies.
|
||||
|
||||
Acceptance means SENT, not delivered. Delivery is at least once: a crash after
|
||||
acceptance can cause a duplicate. Limits count attempts across replicas in rolling
|
||||
minute and 24-hour windows, not recipients or successful deliveries. Leave room
|
||||
for retries, recipient quotas and other applications using the same account.
|
||||
|
||||
## SMTP
|
||||
|
||||
Set `kind=SMTP`, `host`, `port`, and `security=NONE`, `STARTTLS`, or `TLS`. For
|
||||
authenticated SMTP, set both credential names; for an unauthenticated relay,
|
||||
leave both empty. Authentication requires encryption except for loopback or an
|
||||
explicit host `allowPlaintextSmtpAuth` configuration.
|
||||
|
||||
Configure the relay's allowed sender domains and route. For public delivery,
|
||||
verify the domain with the operator, publish its DKIM and SPF records, and align
|
||||
the From domain with DMARC. Update the existing SPF policy rather than adding a
|
||||
second SPF record. Set limits below the relay's quota. SMTP has 10-second connect
|
||||
and 15-second read/write timeouts; there is no provider event webhook here.
|
||||
|
||||
## Amazon SES
|
||||
|
||||
Set `kind=SES`, `region`, and a verified `from_address`. Optionally set
|
||||
`sending_domain`, `configuration_set` and `sns_topic_arn`. Leave credential names
|
||||
empty for the AWS default chain (workload/instance role or AWS environment);
|
||||
otherwise supply both access-key and secret-key environment names.
|
||||
|
||||
Verify the sender identity in the sending region and publish SES's DKIM records.
|
||||
Configure SPF for a custom MAIL FROM domain if used. Request production access
|
||||
when leaving the sandbox. Configure the configuration set's SNS event destination
|
||||
and put the topic ARN in `sns_topic_arn`, not a secret field.
|
||||
|
||||
Sending quotas are regional and account-specific, with sandbox, recipient and
|
||||
rate restrictions. Set both rate fields to fit those quotas. SES sends raw MIME
|
||||
with explicit envelope destinations and strips Bcc from its MIME headers.
|
||||
|
||||
## Postmark
|
||||
|
||||
Set `kind=POSTMARK`, `secret_env_name=POSTMARK_SERVER_TOKEN`, a verified
|
||||
`from_address`, and optionally `sending_domain`. Use a server token, not an
|
||||
account token. `message_stream` defaults to `outbound`; another transactional or
|
||||
broadcast stream must exist on that server. `track_opens` and `track_clicks`
|
||||
default to false; click tracking maps to HtmlAndText or None.
|
||||
|
||||
Verify the domain or sender signature, publish Postmark's DKIM and recommended
|
||||
Return-Path/SPF DNS records, obtain account approval and create the desired
|
||||
message stream. The endpoint is `https://api.postmarkapp.com/email`;
|
||||
the returned `MessageID` is retained.
|
||||
|
||||
| Signal | Outcome |
|
||||
|---|---|
|
||||
| ErrorCode 10 | Provider failure: bad token |
|
||||
| ErrorCode 300 / 406 | Permanent: invalid request / inactive recipient |
|
||||
| ErrorCode 400/401, 405, 412, 413, 422 | Provider failure: signature, sending approval or server/account configuration |
|
||||
| ErrorCode 1235/1236 / 1480 | Provider failure: stream / IP configuration |
|
||||
| HTTP 401/403 | Provider failure |
|
||||
| HTTP 429 / 5xx | Transient; takes precedence over a body code |
|
||||
| Other request errors | Permanent; malformed successful responses are transient |
|
||||
|
||||
API ErrorCode 413 is an account problem; HTTP 413 is a payload-size error.
|
||||
Check plan, recipient and payload limits before setting the rate fields. The
|
||||
single-message API documents a 10 MB message limit. See
|
||||
[Postmark API errors and limits](https://postmarkapp.com/developer/api/overview).
|
||||
|
||||
## SendGrid
|
||||
|
||||
Set `kind=SENDGRID`, `secret_env_name=SENDGRID_API_KEY`, a verified `from_address`
|
||||
and optionally `sending_domain`. Create a key with Mail Send permission.
|
||||
Authenticate the domain and publish SendGrid's DKIM/SPF DNS records; single-sender
|
||||
verification can be used for initial tests. Confirm the account can send.
|
||||
|
||||
`track_opens` controls open tracking; `track_clicks` controls HTML and plain-text
|
||||
click tracking. Explicit false values override account tracking defaults.
|
||||
The global endpoint is `https://api.sendgrid.com/v3/mail/send`; only HTTP 202
|
||||
counts as success and `X-Message-Id` becomes the provider message ID.
|
||||
|
||||
One personalization contains To/Cc/Bcc with display names. `custom_args` carries
|
||||
`qqq_email_id`. Reserved headers are filtered case-insensitively: `x-sg-id`,
|
||||
`x-sg-eid`, `received`, `dkim-signature`, `Content-Type`, `Content-Transfer-Encoding`,
|
||||
`To`, `From`, `Subject`, `Reply-To`, `CC`, `BCC`. `List-Unsubscribe` and
|
||||
`List-Unsubscribe-Post` are retained.
|
||||
|
||||
HTTP 401/403 are provider failures, 400/413 permanent, and 429/5xx transient.
|
||||
Set rate limits for the account's plan/API quota. Payload and recipient limits
|
||||
apply separately from outbox attempt limits. See
|
||||
[SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
|
||||
|
||||
## Mailgun
|
||||
|
||||
Set `kind=MAILGUN`, `secret_env_name=MAILGUN_API_KEY`, `sending_domain` (required,
|
||||
a DNS name without a scheme/path), and `region=US` or `EU` (required).
|
||||
The From address domain must match the sending domain.
|
||||
|
||||
Add and verify the domain in that region. Publish Mailgun's SPF/DKIM records and
|
||||
its requested tracking DNS records if enabling tracking. Use an API key allowed
|
||||
to send for that domain. Sandbox domains restrict recipients; check production
|
||||
status and plan/rate quotas before choosing `max_per_minute` / `max_per_day`.
|
||||
Those fields do not enforce MIME-size or recipient limits.
|
||||
|
||||
The endpoint is `https://api.mailgun.net/v3/{sending_domain}/messages.mime`, or
|
||||
`https://api.eu.mailgun.net/v3/{sending_domain}/messages.mime` for EU. Basic auth
|
||||
uses `api:<resolved key>`. Multipart includes all To/Cc/Bcc mailboxes as envelope
|
||||
destinations, MIME `message` with Bcc stripped, `o:tracking-opens`,
|
||||
`o:tracking-clicks` (explicit yes/no from the row), and `v:qqq_email_id`.
|
||||
The response `id` is stored without surrounding angle brackets.
|
||||
|
||||
HTTP 401/403 are provider failures, 400 permanent, and 429/5xx transient.
|
||||
|
||||
## Gmail SMTP
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `kind` | `GMAIL_SMTP` |
|
||||
| `username_env_name` | `GMAIL_USERNAME`: value is the full account email address |
|
||||
| `secret_env_name` | `GMAIL_APP_PASSWORD`: value is the generated app password |
|
||||
| `security` | `STARTTLS` |
|
||||
| `from_address` | Account address or a permitted configured alias |
|
||||
| `max_per_day` | At most about 500 personal / 2000 paid Workspace; lower with headroom |
|
||||
|
||||
The preset always uses `smtp.gmail.com:587` with required STARTTLS, ignoring
|
||||
custom host/port values. Both env names are required; other security values are
|
||||
refused even if plaintext auth is enabled for other relays. An omitted security
|
||||
value uses the preset at the settings layer, but provider forms default to NONE,
|
||||
so select STARTTLS when creating the row.
|
||||
|
||||
Enable 2-step verification and generate an app password for the service; do not
|
||||
use the normal account password. Account/admin security policy may disable app
|
||||
passwords. A Google password change revokes existing app passwords, requiring a
|
||||
new environment value. See
|
||||
[Google app password setup](https://support.google.com/accounts/answer/185833).
|
||||
|
||||
Google manages domain authentication for personal gmail.com senders. For
|
||||
Workspace, verify the domain and configure Google's SPF/DKIM records and DMARC.
|
||||
Trials, new accounts, SMTP recipient quotas and other account users can reduce
|
||||
available capacity. The 500/2000 limit is an upper planning bound, not guaranteed
|
||||
throughput. See [Workspace limits](https://support.google.com/a/answer/166852)
|
||||
and [personal Gmail limits](https://support.google.com/mail/answer/22839).
|
||||
|
||||
`GMAIL_API` is refused with:
|
||||
`Gmail API sending is specified but not built; use GMAIL_SMTP`.
|
||||
It would require a Google Cloud project, OAuth consent, client credentials and
|
||||
refresh-token management. It adds no needed system-mail capability over the app
|
||||
password preset in this phase and is built only on request (DESIGN 3.6).
|
||||
|
||||
## Event webhooks: phase (c)
|
||||
|
||||
Event ingestion is **not installed by phase (b)**. After phase (c) is deployed,
|
||||
configure provider event delivery at:
|
||||
|
||||
`https://<app>/qqq/hooks/email/{kind}/{providerName}`
|
||||
|
||||
Use the configured provider name and provider-kind route defined by phase (c).
|
||||
Do not expect delivery/bounce/complaint/open/click updates until the receiver
|
||||
exists. Tracking options can operate on the provider side now.
|
||||
|
||||
| Provider | Event setup to prepare |
|
||||
|---|---|
|
||||
| Postmark | Server/stream event webhooks with Basic auth; `POSTMARK_WEBHOOK_PASSWORD` |
|
||||
| SendGrid | Signed Event Webhook; verification key in `SENDGRID_WEBHOOK_PUBLIC_KEY` |
|
||||
| Mailgun | Regional domain event webhooks; `MAILGUN_WEBHOOK_SIGNING_KEY`, separate from the sending key |
|
||||
| SES | Configuration-set events to SNS, HTTP subscription and `sns_topic_arn`; signature/topic verification arrives in phase (c) |
|
||||
| SMTP / Gmail SMTP | No native provider event webhook in this design |
|
||||
|
||||
Webhook environment names are allowlisted now so rows can be prepared; outbound
|
||||
sends never resolve them. Verification, durable event processing and unsubscribe
|
||||
handling arrive in later phases.
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Phase (a), part 1. Apply before producing the qbit. Host security columns must be added by the host.
|
||||
|
||||
CREATE TABLE email_provider (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
enabled BOOLEAN,
|
||||
is_default BOOLEAN,
|
||||
from_address VARCHAR(500),
|
||||
from_name VARCHAR(500),
|
||||
reply_to VARCHAR(500),
|
||||
sending_domain VARCHAR(500),
|
||||
host VARCHAR(500),
|
||||
port INTEGER,
|
||||
security VARCHAR(500),
|
||||
region VARCHAR(500),
|
||||
username_env_name VARCHAR(500),
|
||||
secret_env_name VARCHAR(500),
|
||||
webhook_secret_env_name VARCHAR(500),
|
||||
sns_topic_arn VARCHAR(500),
|
||||
configuration_set VARCHAR(500),
|
||||
message_stream VARCHAR(500),
|
||||
max_per_minute INTEGER,
|
||||
max_per_day INTEGER,
|
||||
track_opens BOOLEAN,
|
||||
track_clicks BOOLEAN,
|
||||
phi_allowed BOOLEAN,
|
||||
health_status VARCHAR(500),
|
||||
last_success_date TIMESTAMP,
|
||||
last_failure_date TIMESTAMP,
|
||||
last_failure_message VARCHAR(500),
|
||||
dispatch_lock INTEGER,
|
||||
default_slot INTEGER,
|
||||
UNIQUE (name),
|
||||
UNIQUE (default_slot)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
status VARCHAR(500),
|
||||
to_addresses TEXT,
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
from_address VARCHAR(500),
|
||||
reply_to VARCHAR(500),
|
||||
subject TEXT,
|
||||
template_id INTEGER,
|
||||
template_version_id INTEGER,
|
||||
brand_id INTEGER,
|
||||
category VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
variables_snapshot TEXT,
|
||||
html_body TEXT,
|
||||
text_body TEXT,
|
||||
body_sha256 VARCHAR(500),
|
||||
idempotency_key VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
provider_message_id VARCHAR(500),
|
||||
attempt_count INTEGER,
|
||||
next_attempt_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
finished_date TIMESTAMP,
|
||||
sent_date TIMESTAMP,
|
||||
delivered_date TIMESTAMP,
|
||||
dead_letter_date TIMESTAMP,
|
||||
linked_table_name VARCHAR(500),
|
||||
linked_record_id VARCHAR(500),
|
||||
sender_qbit VARCHAR(500),
|
||||
sender_process VARCHAR(500),
|
||||
suppressed_addresses TEXT,
|
||||
send_after TIMESTAMP,
|
||||
UNIQUE (idempotency_key)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message_attempt (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
message_id INTEGER NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
outcome VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
claimed_by VARCHAR(500),
|
||||
start_date TIMESTAMP,
|
||||
end_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
duration_millis INTEGER,
|
||||
error VARCHAR(500),
|
||||
UNIQUE (message_id, attempt_number)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
category VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
brand_id INTEGER,
|
||||
active_version_id INTEGER,
|
||||
phi_approved BOOLEAN,
|
||||
phi_approved_by VARCHAR(500),
|
||||
phi_approved_date TIMESTAMP,
|
||||
edit_permission_name VARCHAR(500),
|
||||
owner_qbit VARCHAR(500),
|
||||
description TEXT,
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template_version (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
template_id INTEGER,
|
||||
version_number INTEGER,
|
||||
status VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
subject TEXT,
|
||||
preheader TEXT,
|
||||
body_source TEXT,
|
||||
text_source TEXT,
|
||||
compiled_html TEXT,
|
||||
variables_schema TEXT,
|
||||
sample_variables TEXT,
|
||||
created_by VARCHAR(500),
|
||||
activated_date TIMESTAMP,
|
||||
UNIQUE (template_id, version_number, locale)
|
||||
);
|
||||
|
||||
CREATE TABLE email_brand (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
logo_url VARCHAR(500),
|
||||
logo_dark_url VARCHAR(500),
|
||||
logo_alt VARCHAR(500),
|
||||
primary_color VARCHAR(500),
|
||||
text_color VARCHAR(500),
|
||||
background_color VARCHAR(500),
|
||||
dark_background_color VARCHAR(500),
|
||||
dark_text_color VARCHAR(500),
|
||||
button_text_color VARCHAR(500),
|
||||
footer_markdown TEXT,
|
||||
postal_address TEXT,
|
||||
social_links TEXT,
|
||||
site_url VARCHAR(500),
|
||||
is_default BOOLEAN,
|
||||
app_name VARCHAR(500),
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_suppression (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
address VARCHAR(500),
|
||||
scope VARCHAR(500),
|
||||
reason VARCHAR(500),
|
||||
source_message_id INTEGER,
|
||||
source_event_id INTEGER,
|
||||
note TEXT,
|
||||
created_date TIMESTAMP,
|
||||
expires_date TIMESTAMP,
|
||||
UNIQUE (address, scope)
|
||||
);
|
||||
|
||||
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
|
||||
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
|
||||
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
|
||||
@@ -27,6 +27,9 @@
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<h2.version>2.2.224</h2.version>
|
||||
<commonmark.version>0.27.1</commonmark.version>
|
||||
<awssdk.version>2.41.34</awssdk.version>
|
||||
<greenmail.version>2.0.1</greenmail.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
@@ -77,14 +80,46 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Markdown templates (DESIGN 5.2). -->
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>${commonmark.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
<version>${commonmark.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-autolink</artifactId>
|
||||
<version>${commonmark.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SES transport (DESIGN 3.4): AWS SDK v2 sesv2. -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>sesv2</artifactId>
|
||||
<version>${awssdk.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Webhook and unsubscribe routes (DESIGN 6, 8); the host app provides Javalin. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<groupId>com.icegreen</groupId>
|
||||
<artifactId>greenmail-junit5</artifactId>
|
||||
<version>${greenmail.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -195,7 +230,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a host wires into its ESB, Quick Search and API, without this qbit
|
||||
** depending on those modules (the same opt-in hooks as qbit-sql-workbench).
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@link #esbTableNames()}: publish these tables' inserts, updates and
|
||||
** deletes to an ESB topic (the main tables).</li>
|
||||
** <li>{@link #quickSearchTables()}: index these in Quick Search, with field
|
||||
** weights.</li>
|
||||
** <li>{@link #apiTableNames()}: expose these in the host's QQQ API. The API
|
||||
** gets exactly the admin's rules: the same permissions, customizers and
|
||||
** read-only tables.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class EmailIntegrations
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table for Quick Search: field weights, the timestamp for incremental
|
||||
** indexing, and the record label.
|
||||
*******************************************************************************/
|
||||
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables to publish on the ESB.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames()
|
||||
{
|
||||
return (List.of(EmailTables.MESSAGE, EmailTables.TEMPLATE, EmailTables.SUPPRESSION));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for the host's QQQ API.
|
||||
*******************************************************************************/
|
||||
public static List<String> apiTableNames()
|
||||
{
|
||||
return (List.of(EmailTables.MESSAGE, EmailTables.TEMPLATE, EmailTables.SUPPRESSION));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for Quick Search.
|
||||
*******************************************************************************/
|
||||
public static List<SearchableTable> quickSearchTables()
|
||||
{
|
||||
return (List.of(
|
||||
new SearchableTable(EmailTables.MESSAGE, Map.of("id", 10, "providerMessageId", 8, "status", 4, "linkedRecordId", 3), "modifyDate", "%s", List.of("id")),
|
||||
new SearchableTable(EmailTables.TEMPLATE, Map.of("name", 10, "label", 8, "description", 3), "modifyDate", "%s", List.of("name")),
|
||||
new SearchableTable(EmailTables.SUPPRESSION, Map.of("address", 10, "scope", 4, "reason", 3), "modifyDate", "%s", List.of("address"))));
|
||||
}
|
||||
}
|
||||
@@ -21,41 +21,138 @@
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration for the email qbit. See docs/DESIGN.md.
|
||||
** Configuration for phase (a) email delivery.
|
||||
*******************************************************************************/
|
||||
public class EmailQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String publicBaseUrl;
|
||||
private String defaultProviderName;
|
||||
private String bridgeProviderName = "qbitEmail";
|
||||
private Function<String, String> secretResolver = System::getenv;
|
||||
private boolean allowPlaintextSmtpAuth = false;
|
||||
private Set<String> allowedEnvNames = Set.of();
|
||||
private Integer dispatchIntervalSeconds = 15;
|
||||
private Integer batchSize = 100;
|
||||
private Integer maxAttempts = 6;
|
||||
private List<Integer> retryMinutes = List.of(1, 5, 15, 60, 240, 720);
|
||||
private Integer leaseSeconds = 120;
|
||||
private Integer bodyRetentionDays = 30;
|
||||
private Integer messageRetentionDays = 0;
|
||||
private String appName = "email";
|
||||
private String appLabel = "Email";
|
||||
private List<QFieldMetaData> securityFields = List.of();
|
||||
private List<RecordSecurityLock> recordSecurityLocks = List.of();
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
|
||||
|
||||
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the configuration against the instance.
|
||||
** Whether the host explicitly permits SMTP credentials without TLS.
|
||||
*******************************************************************************/
|
||||
public boolean getAllowPlaintextSmtpAuth()
|
||||
{
|
||||
return (allowPlaintextSmtpAuth);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt in to plaintext SMTP authentication outside loopback.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withAllowPlaintextSmtpAuth(boolean allowPlaintextSmtpAuth)
|
||||
{
|
||||
this.allowPlaintextSmtpAuth = allowPlaintextSmtpAuth;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate against the host instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(!StringUtils.hasContent(backendName))
|
||||
if(backendName == null || backendName.isBlank())
|
||||
{
|
||||
errors.add("EmailQBitConfig: backendName is required");
|
||||
errors.add("backendName is required");
|
||||
}
|
||||
else if(qInstance.getBackend(backendName) == null)
|
||||
else if(instance.getBackend(backendName) == null)
|
||||
{
|
||||
errors.add("EmailQBitConfig: backend not found: " + backendName);
|
||||
errors.add("backend not found: " + backendName);
|
||||
}
|
||||
if(schedulerName != null && instance.getScheduler(schedulerName) == null)
|
||||
{
|
||||
errors.add("schedulerName is not a scheduler in the instance");
|
||||
}
|
||||
if(maxAttempts == null || maxAttempts < 1 || batchSize == null || batchSize < 1 || dispatchIntervalSeconds == null || dispatchIntervalSeconds < 1)
|
||||
{
|
||||
errors.add("Attempts, batch size and dispatch interval must be positive");
|
||||
}
|
||||
if(leaseSeconds == null || leaseSeconds < 60)
|
||||
{
|
||||
errors.add("leaseSeconds must be at least 60, longer than transport timeouts");
|
||||
}
|
||||
if(retryMinutes == null || retryMinutes.isEmpty() || retryMinutes.stream().anyMatch(n -> n == null || n < 1))
|
||||
{
|
||||
errors.add("retryMinutes must contain positive delays");
|
||||
}
|
||||
if(bodyRetentionDays == null || bodyRetentionDays < 0 || messageRetentionDays == null || messageRetentionDays < 0)
|
||||
{
|
||||
errors.add("Retention days must not be negative");
|
||||
}
|
||||
if(secretResolver == null || allowedEnvNames == null || allowedEnvNames.stream().anyMatch(n -> n == null || !ENV_NAME.matcher(n).matches()))
|
||||
{
|
||||
errors.add("A secret resolver and valid allowed environment names are required");
|
||||
}
|
||||
if(bridgeProviderName == null || bridgeProviderName.isBlank() || appName == null || appName.isBlank())
|
||||
{
|
||||
errors.add("Bridge provider and app names are required");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry minutes after a one-based attempt, repeating the final delay.
|
||||
*******************************************************************************/
|
||||
public int retryMinutesAfterAttempt(int number)
|
||||
{
|
||||
return (retryMinutes.get(Math.max(0, Math.min(number - 1, retryMinutes.size() - 1))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Allowed names include the documented defaults for this provider kind.
|
||||
*******************************************************************************/
|
||||
public Set<String> effectiveAllowedEnvNames(String kind)
|
||||
{
|
||||
Set<String> names = new HashSet<>(allowedEnvNames);
|
||||
names.addAll(switch(kind == null ? "" : kind)
|
||||
{
|
||||
case "SMTP", "GMAIL_SMTP" -> Set.of("EMAIL_SMTP_USERNAME", "EMAIL_SMTP_PASSWORD", "EMAIL_GMAIL_USERNAME", "EMAIL_GMAIL_APP_PASSWORD", "GMAIL_USERNAME", "GMAIL_APP_PASSWORD");
|
||||
case "SES" -> Set.of("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN");
|
||||
case "POSTMARK" -> Set.of("EMAIL_POSTMARK_SERVER_TOKEN", "EMAIL_POSTMARK_WEBHOOK_PASSWORD", "POSTMARK_SERVER_TOKEN", "POSTMARK_WEBHOOK_PASSWORD");
|
||||
case "SENDGRID" -> Set.of("EMAIL_SENDGRID_API_KEY", "EMAIL_SENDGRID_WEBHOOK_PUBLIC_KEY", "SENDGRID_API_KEY", "SENDGRID_WEBHOOK_PUBLIC_KEY");
|
||||
case "MAILGUN" -> Set.of("EMAIL_MAILGUN_API_KEY", "EMAIL_MAILGUN_SIGNING_KEY", "MAILGUN_API_KEY", "MAILGUN_WEBHOOK_SIGNING_KEY");
|
||||
case "GMAIL_API" -> Set.of("EMAIL_GMAIL_CLIENT_ID", "EMAIL_GMAIL_CLIENT_SECRET", "EMAIL_GMAIL_REFRESH_TOKEN");
|
||||
default -> Set.of();
|
||||
});
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
** Getter for backendName.
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
@@ -63,13 +160,354 @@ public class EmailQBitConfig implements QBitConfig
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
** Fluent setter for backendName.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publicBaseUrl.
|
||||
*******************************************************************************/
|
||||
public String getPublicBaseUrl()
|
||||
{
|
||||
return (publicBaseUrl);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publicBaseUrl.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withPublicBaseUrl(String publicBaseUrl)
|
||||
{
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultProviderName.
|
||||
*******************************************************************************/
|
||||
public String getDefaultProviderName()
|
||||
{
|
||||
return (defaultProviderName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultProviderName.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withDefaultProviderName(String defaultProviderName)
|
||||
{
|
||||
this.defaultProviderName = defaultProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bridgeProviderName.
|
||||
*******************************************************************************/
|
||||
public String getBridgeProviderName()
|
||||
{
|
||||
return (bridgeProviderName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bridgeProviderName.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withBridgeProviderName(String bridgeProviderName)
|
||||
{
|
||||
this.bridgeProviderName = bridgeProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for secretResolver.
|
||||
*******************************************************************************/
|
||||
public Function<String, String> getSecretResolver()
|
||||
{
|
||||
return (secretResolver);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for secretResolver.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withSecretResolver(Function<String, String> secretResolver)
|
||||
{
|
||||
this.secretResolver = secretResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedEnvNames.
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedEnvNames()
|
||||
{
|
||||
return (allowedEnvNames);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedEnvNames.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withAllowedEnvNames(Set<String> allowedEnvNames)
|
||||
{
|
||||
this.allowedEnvNames = allowedEnvNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dispatchIntervalSeconds.
|
||||
*******************************************************************************/
|
||||
public Integer getDispatchIntervalSeconds()
|
||||
{
|
||||
return (dispatchIntervalSeconds);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dispatchIntervalSeconds.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withDispatchIntervalSeconds(Integer dispatchIntervalSeconds)
|
||||
{
|
||||
this.dispatchIntervalSeconds = dispatchIntervalSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for batchSize.
|
||||
*******************************************************************************/
|
||||
public Integer getBatchSize()
|
||||
{
|
||||
return (batchSize);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for batchSize.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withBatchSize(Integer batchSize)
|
||||
{
|
||||
this.batchSize = batchSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxAttempts.
|
||||
*******************************************************************************/
|
||||
public Integer getMaxAttempts()
|
||||
{
|
||||
return (maxAttempts);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxAttempts.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withMaxAttempts(Integer maxAttempts)
|
||||
{
|
||||
this.maxAttempts = maxAttempts;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for retryMinutes.
|
||||
*******************************************************************************/
|
||||
public List<Integer> getRetryMinutes()
|
||||
{
|
||||
return (retryMinutes);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for retryMinutes.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withRetryMinutes(List<Integer> retryMinutes)
|
||||
{
|
||||
this.retryMinutes = retryMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for leaseSeconds.
|
||||
*******************************************************************************/
|
||||
public Integer getLeaseSeconds()
|
||||
{
|
||||
return (leaseSeconds);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for leaseSeconds.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withLeaseSeconds(Integer leaseSeconds)
|
||||
{
|
||||
this.leaseSeconds = leaseSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bodyRetentionDays.
|
||||
*******************************************************************************/
|
||||
public Integer getBodyRetentionDays()
|
||||
{
|
||||
return (bodyRetentionDays);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bodyRetentionDays.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withBodyRetentionDays(Integer bodyRetentionDays)
|
||||
{
|
||||
this.bodyRetentionDays = bodyRetentionDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for messageRetentionDays.
|
||||
*******************************************************************************/
|
||||
public Integer getMessageRetentionDays()
|
||||
{
|
||||
return (messageRetentionDays);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for messageRetentionDays.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withMessageRetentionDays(Integer messageRetentionDays)
|
||||
{
|
||||
this.messageRetentionDays = messageRetentionDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appName.
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (appName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appLabel.
|
||||
*******************************************************************************/
|
||||
public String getAppLabel()
|
||||
{
|
||||
return (appLabel);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appLabel.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for securityFields.
|
||||
*******************************************************************************/
|
||||
public List<QFieldMetaData> getSecurityFields()
|
||||
{
|
||||
return (securityFields);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for securityFields.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
|
||||
{
|
||||
this.securityFields = securityFields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordSecurityLocks.
|
||||
*******************************************************************************/
|
||||
public List<RecordSecurityLock> getRecordSecurityLocks()
|
||||
{
|
||||
return (recordSecurityLocks);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordSecurityLocks.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
|
||||
{
|
||||
this.recordSecurityLocks = recordSecurityLocks;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
|
||||
{
|
||||
return (tableMetaDataCustomizer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public EmailQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
|
||||
{
|
||||
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,30 @@ package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.email.delivery.EmailAlerts;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point of the email qbit (scaffold: the tables, providers and app
|
||||
** arrive in phase (a), see docs/DESIGN.md).
|
||||
** Entry point of the email outbox qbit.
|
||||
*******************************************************************************/
|
||||
public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
{
|
||||
@@ -112,8 +125,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the config and register the qbit (scaffold: the email_ tables,
|
||||
** providers, processes and app arrive in phase (a), docs/DESIGN.md).
|
||||
** Validate and register the tables, bridge, processes, alerts and app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
@@ -125,6 +137,59 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
throw (new QException("Email qbit configuration is invalid: " + String.join("; ", errors)));
|
||||
}
|
||||
qInstance.addQBit(getQBitMetaData());
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
EmailTables.possibleValueSources().forEach(output::add);
|
||||
Map<String, QTableMetaData> tables = new LinkedHashMap<>();
|
||||
for(QTableMetaData table : EmailTables.tables(qBitConfig))
|
||||
{
|
||||
Class<?> customizer = EmailTables.PROVIDER.equals(table.getName()) ? EmailTableCustomizers.ProviderCustomizer.class
|
||||
: EmailTables.SUPPRESSION.equals(table.getName()) ? EmailTableCustomizers.SuppressionCustomizer.class : null;
|
||||
if(customizer != null)
|
||||
{
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(customizer))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(customizer))
|
||||
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(customizer));
|
||||
}
|
||||
if(EmailTables.MESSAGE.equals(table.getName()))
|
||||
{
|
||||
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.MessageLinksCustomizer.class));
|
||||
}
|
||||
if(EmailTables.PROVIDER.equals(table.getName()))
|
||||
{
|
||||
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EmailTableCustomizers.ProviderCustomizer.class));
|
||||
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
|
||||
{
|
||||
table.withVirtualField(new QVirtualFieldMetaData(field + "Status", QFieldType.STRING)
|
||||
.withLabel(field.equals("usernameEnvName") ? "Username set" : field.equals("secretEnvName") ? "Secret set" : "Webhook secret set")
|
||||
.withIsEditable(false).withIsQuerySelectable(true));
|
||||
}
|
||||
table.getSections().get(1).withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, section ->
|
||||
{
|
||||
List<String> fields = new ArrayList<>(section.getFieldNames());
|
||||
fields.addAll(List.of("usernameEnvNameStatus", "secretEnvNameStatus", "webhookSecretEnvNameStatus"));
|
||||
section.setFieldNames(fields);
|
||||
});
|
||||
}
|
||||
if(qBitConfig.getTableMetaDataCustomizer() != null)
|
||||
{
|
||||
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
}
|
||||
table.setSourceQBitName(getQBitMetaData().getName());
|
||||
tables.put(table.getName(), table);
|
||||
output.add(table);
|
||||
}
|
||||
EmailTables.joins().forEach(output::add);
|
||||
EmailTables.childWidgets().forEach(output::add);
|
||||
EmailApp.widgets().forEach(output::add);
|
||||
EmailProcesses.processes(qBitConfig).forEach(process ->
|
||||
{
|
||||
process.setSourceQBitName(getQBitMetaData().getName());
|
||||
output.add(process);
|
||||
});
|
||||
EmailAlerts.types().forEach(output::add);
|
||||
output.add(EmailApp.app(qBitConfig, tables));
|
||||
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
output.addSelfToInstance(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runtime lookups for the qbit.
|
||||
**
|
||||
** <p>The config is read from the current QInstance on every call (never held
|
||||
** in a static), so reconfiguring, or two instances in one JVM, never see
|
||||
** stale settings. The only JVM-wide state is the runner identity
|
||||
** and the clock, which tests replace.</p>
|
||||
*******************************************************************************/
|
||||
public final class EmailRuntime
|
||||
{
|
||||
|
||||
private static final String NODE_ID = ManagementFactory.getRuntimeMXBean().getName() + "/" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
private static volatile Clock clock = Clock.systemUTC();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailRuntime()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The config in the current instance, or null when the qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public static EmailQBitConfig config()
|
||||
{
|
||||
return (config(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The config in an instance, or null when the qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public static EmailQBitConfig config(QInstance qInstance)
|
||||
{
|
||||
if(qInstance == null || qInstance.getQBits() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(QBitMetaData qBit : qInstance.getQBits().values())
|
||||
{
|
||||
if(EmailQBitProducer.ARTIFACT_ID.equals(qBit.getArtifactId()) && qBit.getConfig() instanceof EmailQBitConfig config)
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the qbit is installed in the current instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled()
|
||||
{
|
||||
return (config() != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The clock (tests replace it).
|
||||
*******************************************************************************/
|
||||
public static Clock clock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the clock (tests).
|
||||
*******************************************************************************/
|
||||
public static void setClock(Clock newClock)
|
||||
{
|
||||
clock = newClock == null ? Clock.systemUTC() : newClock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runner identity stored on claimed attempts.
|
||||
*******************************************************************************/
|
||||
public static String nodeId()
|
||||
{
|
||||
return (NODE_ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.app;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email app with a placeholder overview and the five main tables in navigation.
|
||||
** Versions and attempts are child lists, and processes are record actions.
|
||||
*******************************************************************************/
|
||||
public final class EmailApp
|
||||
{
|
||||
public static final String HOME_WIDGET = "emailHome";
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailApp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Placeholder home widget.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(EmailTables.permissionRules())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app, with the main tables as children.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(EmailQBitConfig config, Map<String, QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.getAppName())
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("mail"))
|
||||
.withPermissionRules(EmailTables.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET));
|
||||
for(String name : EmailTables.MAIN)
|
||||
{
|
||||
if(tables.containsKey(name))
|
||||
{
|
||||
app.withChild(tables.get(name));
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home renderer.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, "<p>Use Messages to follow queued email and Providers to manage delivery.</p>")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.bridge;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
|
||||
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.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adapts core messaging calls to durable asynchronous outbox inserts.
|
||||
*******************************************************************************/
|
||||
public class EmailOutboxMessagingProvider implements MessagingProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Distinct provider type.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (EmailOutboxMessagingProviderMetaData.TYPE);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Map core parties and alternatives; refuse unsupported attachments.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
|
||||
{
|
||||
if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty())
|
||||
{
|
||||
throw (new QException("Email attachments are not supported in part 1"));
|
||||
}
|
||||
EmailRequest request = new EmailRequest().withSubject(input.getSubject());
|
||||
parties(request, input.getTo(), EmailPartyRole.TO);
|
||||
parties(request, input.getFrom(), EmailPartyRole.FROM);
|
||||
if(input.getContentList() != null)
|
||||
{
|
||||
for(var content : input.getContentList())
|
||||
{
|
||||
if(content.getContentRole() == EmailContentRole.HTML)
|
||||
{
|
||||
request.withHtmlBody(content.getBody());
|
||||
}
|
||||
else if(content.getContentRole() == EmailContentRole.TEXT || content.getContentRole() == null)
|
||||
{
|
||||
request.withTextBody(content.getBody());
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new QException("Unsupported email content role"));
|
||||
}
|
||||
}
|
||||
}
|
||||
EmailSendResult result = EmailService.send(request);
|
||||
if(result.status() == EmailSendResult.Status.REFUSED)
|
||||
{
|
||||
throw (new QException(String.join("; ", result.reasons())));
|
||||
}
|
||||
return (new SendMessageOutput());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Walk nested MultiParty inputs, inheriting a parent role when omitted.
|
||||
*******************************************************************************/
|
||||
private static void parties(EmailRequest request, Party party, EmailPartyRole inherited) throws QException
|
||||
{
|
||||
if(party == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EmailPartyRole role = party.getRole() instanceof EmailPartyRole explicit ? explicit : inherited;
|
||||
if(party instanceof MultiParty multi)
|
||||
{
|
||||
if(multi.getPartyList() != null)
|
||||
{
|
||||
for(Party child : multi.getPartyList())
|
||||
{
|
||||
parties(request, child, role);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
String address = party.getAddress();
|
||||
try
|
||||
{
|
||||
if(party.getLabel() != null)
|
||||
{
|
||||
address = new InternetAddress(address, party.getLabel(), "UTF-8").toString();
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid email party"));
|
||||
}
|
||||
switch(role)
|
||||
{
|
||||
case TO -> request.withTo(address);
|
||||
case CC -> request.withCc(address);
|
||||
case BCC -> request.withBcc(address);
|
||||
case FROM -> request.withFrom(address);
|
||||
case REPLY_TO -> request.withReplyTo(address);
|
||||
default -> throw (new QException("Unsupported email party role"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.bridge;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core messaging provider type registered independently of EMAIL and SES.
|
||||
*******************************************************************************/
|
||||
public class EmailOutboxMessagingProviderMetaData extends QMessagingProviderMetaData
|
||||
{
|
||||
public static final String TYPE = "QBIT_EMAIL";
|
||||
|
||||
static
|
||||
{
|
||||
QMessagingProviderDispatcher.registerMessagingProvider(new EmailOutboxMessagingProvider());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The bridge type.
|
||||
*******************************************************************************/
|
||||
public EmailOutboxMessagingProviderMetaData()
|
||||
{
|
||||
setType(TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.delivery;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional alert sink integration with content-free signals.
|
||||
*******************************************************************************/
|
||||
public final class EmailAlerts
|
||||
{
|
||||
public static final String UNHEALTHY = "email.provider.unhealthy";
|
||||
public static final String DEAD_LETTER = "email.message.dead-letter";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare alert types using RC.5 dotted-name syntax.
|
||||
*******************************************************************************/
|
||||
public static List<QAlertTypeMetaData> types()
|
||||
{
|
||||
return (List.of(new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Email provider unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
|
||||
.withSubjectTableName(EmailTables.PROVIDER).withVisibilityPermission("email.admin").withSourceName("email"),
|
||||
new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Email dead letters").withDefaultSeverity(AlertSeverity.WARNING)
|
||||
.withSubjectTableName(EmailTables.MESSAGE).withVisibilityPermission("email.view").withSourceName("email")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise without addresses, provider diagnostics or content.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, Integer providerId)
|
||||
{
|
||||
String key = "provider:" + providerId + (DEAD_LETTER.equals(type) ? ":" + EmailRuntime.clock().instant().getEpochSecond() / 3600 : "");
|
||||
QAlerts.raise(new AlertSignal(type).withDedupKey(key).withTitle(UNHEALTHY.equals(type) ? "Email provider unhealthy" : "Email message dead-lettered")
|
||||
.withSummary("Review the Email tables for status and retry actions."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve on recovery; core guards a missing or failing sink.
|
||||
*******************************************************************************/
|
||||
public static void recovered(Integer providerId)
|
||||
{
|
||||
QAlerts.resolve(UNHEALTHY, "provider:" + providerId, "An email attempt succeeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* 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.delivery;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.transport.EmailEnvelope;
|
||||
import com.kingsrook.qbits.email.transport.EmailProviderSettings;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportException;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportRegistry;
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable at-least-once delivery with unique attempt claims and database leases.
|
||||
*******************************************************************************/
|
||||
public class EmailDispatcher
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(EmailDispatcher.class);
|
||||
|
||||
public static final List<String> DUE = List.of("QUEUED", "RETRY_WAIT");
|
||||
public static final List<String> FINAL = List.of("SENT", "DELIVERED", "DEAD_LETTER", "BOUNCED", "COMPLAINED", "SUPPRESSED", "CANCELLED");
|
||||
private final EmailTransportRegistry transports;
|
||||
private final String runner = EmailRuntime.nodeId() + "/" + UUID.randomUUID();
|
||||
|
||||
/*******************************************************************************
|
||||
** Default delivery transports.
|
||||
*******************************************************************************/
|
||||
public EmailDispatcher()
|
||||
{
|
||||
this(new EmailTransportRegistry());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject transport registry for host extensions and tests.
|
||||
*******************************************************************************/
|
||||
public EmailDispatcher(EmailTransportRegistry transports)
|
||||
{
|
||||
this.transports = transports;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One message delivery result.
|
||||
*******************************************************************************/
|
||||
public enum SendResult
|
||||
{
|
||||
SENT,
|
||||
RETRY_WAIT,
|
||||
DEAD_LETTER,
|
||||
PROVIDER_FAILURE,
|
||||
NOT_SENT
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts from a dispatch pass.
|
||||
*******************************************************************************/
|
||||
public record RunSummary(int sent, int failed, int skipped)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A database claim, fully committed before network I/O.
|
||||
*******************************************************************************/
|
||||
private record Claim(QRecord message, QRecord provider, QRecord attempt)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Dispatch the configured batch.
|
||||
*******************************************************************************/
|
||||
public RunSummary runOnce() throws QException
|
||||
{
|
||||
return (run(null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Dispatch due message ids, or all due messages when null.
|
||||
*******************************************************************************/
|
||||
public RunSummary run(List<Integer> ids) throws QException
|
||||
{
|
||||
if(EmailRuntime.config() == null || (ids != null && ids.isEmpty()))
|
||||
{
|
||||
return (new RunSummary(0, 0, 0));
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT", "SENDING"))
|
||||
.withCriteria(new QFilterCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, EmailRuntime.clock().instant()))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(EmailRuntime.config().getBatchSize());
|
||||
if(ids != null)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<>(ids)));
|
||||
}
|
||||
int sent = 0;
|
||||
int failed = 0;
|
||||
int skipped = 0;
|
||||
Integer afterId = 0;
|
||||
Set<Integer> failedProviders = new HashSet<>();
|
||||
while(sent + failed < EmailRuntime.config().getBatchSize())
|
||||
{
|
||||
QQueryFilter pageFilter = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, afterId));
|
||||
List<QRecord> page = SystemWrites.query(EmailTables.MESSAGE, pageFilter);
|
||||
if(page.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord row : page)
|
||||
{
|
||||
afterId = row.getValueInteger("id");
|
||||
if(failedProviders.contains(row.getValueInteger("providerId")))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
SendResult result = attempt(afterId, false);
|
||||
switch(result)
|
||||
{
|
||||
case SENT -> sent++;
|
||||
case RETRY_WAIT, DEAD_LETTER -> failed++;
|
||||
case PROVIDER_FAILURE ->
|
||||
{
|
||||
failed++;
|
||||
failedProviders.add(row.getValueInteger("providerId"));
|
||||
}
|
||||
default -> skipped++;
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
failed++;
|
||||
LOG.warn("Email attempt failed", logPair("messageId", afterId));
|
||||
}
|
||||
if(sent + failed >= EmailRuntime.config().getBatchSize())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new RunSummary(sent, failed, skipped));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual send bypasses due time but preserves rate limits, health and leases.
|
||||
*******************************************************************************/
|
||||
public SendResult sendNow(Integer id) throws QException
|
||||
{
|
||||
SendResult result = attempt(id, true);
|
||||
return (result == SendResult.PROVIDER_FAILURE ? SendResult.RETRY_WAIT : result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Claim, send outside the transaction, then persist the fenced result.
|
||||
*******************************************************************************/
|
||||
protected SendResult attempt(Integer id, boolean now) throws QException
|
||||
{
|
||||
Claim claim = claim(id, now);
|
||||
if(claim == null)
|
||||
{
|
||||
return (SendResult.NOT_SENT);
|
||||
}
|
||||
EmailTransportResult result = null;
|
||||
EmailTransportException error = null;
|
||||
try
|
||||
{
|
||||
result = transports.get(claim.provider().getValueString("kind")).send(envelope(claim.message()), new EmailProviderSettings(claim.provider(), EmailRuntime.config()));
|
||||
}
|
||||
catch(EmailTransportException e)
|
||||
{
|
||||
error = e;
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
error = new EmailTransportException("Transport temporarily unavailable", false);
|
||||
}
|
||||
return (finish(claim, result, error));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unique attempt insertion under a short provider lock, including rate checks.
|
||||
*******************************************************************************/
|
||||
private Claim claim(Integer id, boolean immediate) throws QException
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
QRecord initial = EmailStore.get(EmailTables.MESSAGE, id, null);
|
||||
if(initial == null || config == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
QRecord provider = EmailStore.lockProvider(initial.getValueInteger("providerId"), tx);
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, id, tx);
|
||||
Instant time = EmailRuntime.clock().instant();
|
||||
if(provider == null || message == null || !Boolean.TRUE.equals(provider.getValueBoolean("enabled")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String status = message.getValueString("status");
|
||||
int previous = message.getValueInteger("attemptCount") == null ? 0 : message.getValueInteger("attemptCount");
|
||||
if("SENDING".equals(status))
|
||||
{
|
||||
Instant lease = message.getValueInstant("leaseExpiresDate");
|
||||
if(lease != null && lease.isAfter(time))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
expire(message, tx, time);
|
||||
if(budgetAttempts(id, previous, tx) >= config.getMaxAttempts())
|
||||
{
|
||||
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "DEAD_LETTER")
|
||||
.withValue("deadLetterDate", time).withValue("finishedDate", time).withValue("leaseExpiresDate", null).withValue("nextAttemptDate", null).withValue("lastError", "Attempt lease expired"), tx);
|
||||
tx.commit();
|
||||
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, provider.getValueInteger("id")));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
else if(!DUE.contains(status))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!immediate && ((message.getValueInstant("sendAfter") != null && message.getValueInstant("sendAfter").isAfter(time))
|
||||
|| (message.getValueInstant("nextAttemptDate") != null && message.getValueInstant("nextAttemptDate").isAfter(time))))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(limited(provider, tx, time))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Instant lease = time.plusSeconds(config.getLeaseSeconds());
|
||||
QRecord attempt = new QRecord().withValue("messageId", id).withValue("providerId", provider.getValue("id"))
|
||||
.withValue("attemptNumber", previous + 1).withValue("outcome", "IN_FLIGHT").withValue("claimedBy", runner)
|
||||
.withValue("startDate", time).withValue("leaseExpiresDate", lease);
|
||||
for(var field : config.getSecurityFields())
|
||||
{
|
||||
attempt.setValue(field.getName(), message.getValue(field.getName()));
|
||||
}
|
||||
QRecord inserted = SystemWrites.insert(EmailTables.ATTEMPT, List.of(attempt), tx).getRecords().get(0);
|
||||
if(inserted.getValueInteger("id") == null || (inserted.getErrors() != null && !inserted.getErrors().isEmpty()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "SENDING").withValue("attemptCount", previous + 1)
|
||||
.withValue("leaseExpiresDate", lease).withValue("nextAttemptDate", lease), tx);
|
||||
tx.commit();
|
||||
return (new Claim(message, provider, inserted));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared rate and unhealthy-provider probe budgets, counted from attempts.
|
||||
*******************************************************************************/
|
||||
private static boolean limited(QRecord provider, QBackendTransaction transaction, Instant now) throws QException
|
||||
{
|
||||
for(String field : List.of("maxPerMinute", "maxPerDay"))
|
||||
{
|
||||
Integer limit = provider.getValueInteger(field);
|
||||
Instant after = now.minusSeconds("maxPerMinute".equals(field) ? 60 : 86400);
|
||||
if(limit != null && SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("providerId", provider.getValueInteger("id"))
|
||||
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN, after)), transaction) >= limit)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
if("UNHEALTHY".equals(provider.getValueString("healthStatus")))
|
||||
{
|
||||
return (SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("providerId", provider.getValueInteger("id"))
|
||||
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN, now.minusSeconds(300))), transaction) > 0);
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark the abandoned attempt before retrying.
|
||||
*******************************************************************************/
|
||||
private static void expire(QRecord message, QBackendTransaction tx, Instant now) throws QException
|
||||
{
|
||||
for(QRecord attempt : SystemWrites.query(EmailTables.ATTEMPT, EmailStore.equal("messageId", message.getValueInteger("id"))
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "IN_FLIGHT")), tx))
|
||||
{
|
||||
EmailStore.update(EmailTables.ATTEMPT, new QRecord().withValue("id", attempt.getValue("id")).withValue("outcome", "LEASE_EXPIRED")
|
||||
.withValue("endDate", now).withValue("error", "Attempt lease expired"), tx);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fence late completions by attempt number and persist outcome atomically.
|
||||
*******************************************************************************/
|
||||
private SendResult finish(Claim claim, EmailTransportResult result, EmailTransportException error) throws QException
|
||||
{
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
Integer providerId = claim.provider().getValueInteger("id");
|
||||
QRecord provider = EmailStore.lockProvider(providerId, tx);
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, claim.message().getValueInteger("id"), tx);
|
||||
int number = claim.attempt().getValueInteger("attemptNumber");
|
||||
if(message == null || !"SENDING".equals(message.getValueString("status")) || !Integer.valueOf(number).equals(message.getValueInteger("attemptCount")))
|
||||
{
|
||||
return (SendResult.NOT_SENT);
|
||||
}
|
||||
Instant now = EmailRuntime.clock().instant();
|
||||
boolean success = error == null && result != null;
|
||||
boolean permanent = error != null && error.getPermanent();
|
||||
boolean providerLevel = error != null && error.getProviderLevel();
|
||||
String safeError = error == null ? null : permanent ? "Provider rejected the message" : "Provider temporarily unavailable";
|
||||
if(providerLevel)
|
||||
{
|
||||
safeError = "Credentials missing or refused";
|
||||
}
|
||||
if(error != null && "Gmail API sending is specified but not built; use GMAIL_SMTP".equals(error.getMessage()))
|
||||
{
|
||||
safeError = "Gmail API sending is specified but not built; use GMAIL_SMTP";
|
||||
}
|
||||
EmailStore.update(EmailTables.ATTEMPT, new QRecord().withValue("id", claim.attempt().getValue("id"))
|
||||
.withValue("outcome", success ? "SUCCEEDED" : providerLevel ? "PROVIDER_FAILURE" : permanent ? "PERMANENT_FAILURE" : "TRANSIENT_FAILURE")
|
||||
.withValue("endDate", now).withValue("durationMillis", (int) Math.max(0, Duration.between(claim.attempt().getValueInstant("startDate"), now).toMillis()))
|
||||
.withValue("error", safeError), tx);
|
||||
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("leaseExpiresDate", null).withValue("lastError", safeError);
|
||||
SendResult outcome;
|
||||
if(success)
|
||||
{
|
||||
outcome = SendResult.SENT;
|
||||
update.withValue("status", "SENT").withValue("sentDate", now).withValue("finishedDate", now).withValue("providerMessageId", result.providerMessageId()).withValue("nextAttemptDate", null);
|
||||
EmailStore.update(EmailTables.PROVIDER, new QRecord().withValue("id", providerId).withValue("healthStatus", "HEALTHY").withValue("lastSuccessDate", now), tx);
|
||||
}
|
||||
else
|
||||
{
|
||||
boolean dead = permanent || (!providerLevel && budgetAttempts(message.getValueInteger("id"), number, tx) >= EmailRuntime.config().getMaxAttempts());
|
||||
outcome = dead ? SendResult.DEAD_LETTER : SendResult.RETRY_WAIT;
|
||||
long seconds = 60L * EmailRuntime.config().retryMinutesAfterAttempt(number);
|
||||
if(error != null && error.getRetryAfter() != null)
|
||||
{
|
||||
seconds = Math.max(1, Math.min(3600, error.getRetryAfter().getSeconds()));
|
||||
}
|
||||
update.withValue("status", outcome.name()).withValue("deadLetterDate", dead ? now : null).withValue("finishedDate", dead ? now : null).withValue("nextAttemptDate", dead ? null : now.plusSeconds(seconds));
|
||||
QRecord health = new QRecord().withValue("id", providerId).withValue("lastFailureDate", now).withValue("lastFailureMessage", safeError);
|
||||
if(providerLevel || (!permanent && consecutiveFailures(providerId, tx) >= 10))
|
||||
{
|
||||
health.withValue("healthStatus", "UNHEALTHY");
|
||||
}
|
||||
EmailStore.update(EmailTables.PROVIDER, health, tx);
|
||||
}
|
||||
EmailStore.update(EmailTables.MESSAGE, update, tx);
|
||||
QRecord updatedProvider = EmailStore.get(EmailTables.PROVIDER, providerId, tx);
|
||||
tx.commit();
|
||||
if(success && "UNHEALTHY".equals(provider.getValueString("healthStatus")))
|
||||
{
|
||||
alert(() -> EmailAlerts.recovered(providerId));
|
||||
}
|
||||
if("UNHEALTHY".equals(updatedProvider.getValueString("healthStatus")) && !"UNHEALTHY".equals(provider.getValueString("healthStatus")))
|
||||
{
|
||||
alert(() -> EmailAlerts.raise(EmailAlerts.UNHEALTHY, providerId));
|
||||
}
|
||||
if(outcome == SendResult.DEAD_LETTER)
|
||||
{
|
||||
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, providerId));
|
||||
}
|
||||
LOG.info("Email attempt completed", logPair("messageId", message.getValue("id")), logPair("status", outcome.name()));
|
||||
return (providerLevel ? SendResult.PROVIDER_FAILURE : outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep claim numbering monotonic while excluding provider failures from limits.
|
||||
*******************************************************************************/
|
||||
private static long budgetAttempts(Integer messageId, int attempts, QBackendTransaction tx) throws QException
|
||||
{
|
||||
return (attempts - SystemWrites.count(EmailTables.ATTEMPT, EmailStore.equal("messageId", messageId)
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "PROVIDER_FAILURE")), tx));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional alerting must never change the result of a committed delivery.
|
||||
*******************************************************************************/
|
||||
private static void alert(Runnable notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
notification.run();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Email alert unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consecutive completed transient failures; a success or permanent rejection breaks the streak.
|
||||
*******************************************************************************/
|
||||
private static int consecutiveFailures(Integer providerId, QBackendTransaction tx) throws QException
|
||||
{
|
||||
int failures = 0;
|
||||
for(QRecord attempt : SystemWrites.query(EmailTables.ATTEMPT, EmailStore.equal("providerId", providerId)
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.NOT_EQUALS, "IN_FLIGHT"))
|
||||
.withOrderBy(new QFilterOrderBy("endDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10), tx))
|
||||
{
|
||||
if(!"TRANSIENT_FAILURE".equals(attempt.getValueString("outcome")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
failures++;
|
||||
}
|
||||
return (failures);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The persisted alternatives are reused verbatim on each attempt.
|
||||
*******************************************************************************/
|
||||
private static EmailEnvelope envelope(QRecord message)
|
||||
{
|
||||
return (new EmailEnvelope().withMessageId(message.getValueString("id")).withFrom(message.getValueString("fromAddress"))
|
||||
.withTo(split(message.getValueString("toAddresses"))).withCc(split(message.getValueString("ccAddresses"))).withBcc(split(message.getValueString("bccAddresses")))
|
||||
.withReplyTo(message.getValueString("replyTo")).withSubject(message.getValueString("subject"))
|
||||
.withHtmlBody(message.getValueString("htmlBody")).withTextBody(message.getValueString("textBody")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve quoted display names by treating the stored list as one RFC address list.
|
||||
*******************************************************************************/
|
||||
private static List<String> split(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? List.of() : List.of(value));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Lease recovery and five-minute provider probes share the normal claim path.
|
||||
*******************************************************************************/
|
||||
public RunSummary maintain() throws QException
|
||||
{
|
||||
Instant now = EmailRuntime.clock().instant();
|
||||
for(QRecord candidate : SystemWrites.query(EmailTables.MESSAGE, EmailStore.equal("status", "SENDING")
|
||||
.withCriteria(new QFilterCriteria("leaseExpiresDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))))
|
||||
{
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailStore.lockProvider(candidate.getValueInteger("providerId"), tx);
|
||||
QRecord current = EmailStore.get(EmailTables.MESSAGE, candidate.getValueInteger("id"), tx);
|
||||
if(current == null || !"SENDING".equals(current.getValueString("status")) || current.getValueInstant("leaseExpiresDate").isAfter(now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
expire(current, tx, now);
|
||||
boolean dead = budgetAttempts(current.getValueInteger("id"), current.getValueInteger("attemptCount"), tx) >= EmailRuntime.config().getMaxAttempts();
|
||||
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", current.getValue("id"))
|
||||
.withValue("status", dead ? "DEAD_LETTER" : "RETRY_WAIT").withValue("nextAttemptDate", dead ? null : now)
|
||||
.withValue("leaseExpiresDate", null).withValue("lastError", "Attempt lease expired")
|
||||
.withValue("deadLetterDate", dead ? now : null).withValue("finishedDate", dead ? now : null), tx);
|
||||
tx.commit();
|
||||
if(dead)
|
||||
{
|
||||
alert(() -> EmailAlerts.raise(EmailAlerts.DEAD_LETTER, candidate.getValueInteger("providerId")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (runOnce());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enumerated database values and their display labels.
|
||||
*******************************************************************************/
|
||||
public final class EmailEnums
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailEnums()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ProviderKind values.
|
||||
*******************************************************************************/
|
||||
public enum ProviderKind implements PossibleValueEnum<String>
|
||||
{
|
||||
SMTP,
|
||||
SES,
|
||||
POSTMARK,
|
||||
SENDGRID,
|
||||
MAILGUN,
|
||||
GMAIL_SMTP,
|
||||
GMAIL_API;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Security values.
|
||||
*******************************************************************************/
|
||||
public enum Security implements PossibleValueEnum<String>
|
||||
{
|
||||
NONE,
|
||||
STARTTLS,
|
||||
TLS;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MessageStatus values.
|
||||
*******************************************************************************/
|
||||
public enum MessageStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
QUEUED,
|
||||
SENDING,
|
||||
SENT,
|
||||
DELIVERED,
|
||||
RETRY_WAIT,
|
||||
DEAD_LETTER,
|
||||
BOUNCED,
|
||||
COMPLAINED,
|
||||
SUPPRESSED,
|
||||
CANCELLED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AttemptOutcome values.
|
||||
*******************************************************************************/
|
||||
public enum AttemptOutcome implements PossibleValueEnum<String>
|
||||
{
|
||||
IN_FLIGHT,
|
||||
SUCCEEDED,
|
||||
TRANSIENT_FAILURE,
|
||||
PROVIDER_FAILURE,
|
||||
PERMANENT_FAILURE,
|
||||
LEASE_EXPIRED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HealthStatus values.
|
||||
*******************************************************************************/
|
||||
public enum HealthStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
HEALTHY,
|
||||
UNHEALTHY;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TemplateKind values.
|
||||
*******************************************************************************/
|
||||
public enum TemplateKind implements PossibleValueEnum<String>
|
||||
{
|
||||
MARKDOWN,
|
||||
MJML,
|
||||
HTML;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** VersionStatus values.
|
||||
*******************************************************************************/
|
||||
public enum VersionStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
RETIRED;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SuppressionScope values.
|
||||
*******************************************************************************/
|
||||
public enum SuppressionScope implements PossibleValueEnum<String>
|
||||
{
|
||||
ALL,
|
||||
TRANSACTIONAL,
|
||||
NOTIFICATION,
|
||||
MARKETING,
|
||||
SECURITY;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SuppressionReason values.
|
||||
*******************************************************************************/
|
||||
public enum SuppressionReason implements PossibleValueEnum<String>
|
||||
{
|
||||
HARD_BOUNCE,
|
||||
COMPLAINT,
|
||||
UNSUBSCRIBE,
|
||||
MANUAL;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT).replace('_', ' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qbits.email.transport.EmailProviderSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider and suppression validation shared by UI, API and code.
|
||||
*******************************************************************************/
|
||||
public final class EmailTableCustomizers
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailTableCustomizers()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merged values for sparse updates.
|
||||
*******************************************************************************/
|
||||
private static QRecord effective(QRecord record, Optional<List<QRecord>> oldRecords)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
if(oldRecords != null && oldRecords.isPresent())
|
||||
{
|
||||
oldRecords.get().stream().filter(r -> Objects.equals(r.getValue("id"), record.getValue("id"))).findFirst()
|
||||
.ifPresent(r -> merged.getValues().putAll(r.getValues()));
|
||||
}
|
||||
merged.getValues().putAll(record.getValues());
|
||||
return (merged);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate a provider without resolving any credentials.
|
||||
*******************************************************************************/
|
||||
public static List<String> validateProvider(QRecord row, EmailQBitConfig config)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
String kind = row.getValueString("kind");
|
||||
if("GMAIL_API".equals(kind))
|
||||
{
|
||||
errors.add("Gmail API sending is specified but not built; use GMAIL_SMTP");
|
||||
}
|
||||
if(List.of("POSTMARK", "SENDGRID", "MAILGUN", "GMAIL_SMTP").contains(kind == null ? "" : kind)
|
||||
&& (row.getValueString("secretEnvName") == null || row.getValueString("secretEnvName").isBlank()))
|
||||
{
|
||||
errors.add("Provider needs a secret environment name");
|
||||
}
|
||||
if("GMAIL_SMTP".equals(kind))
|
||||
{
|
||||
if(row.getValueString("usernameEnvName") == null || row.getValueString("usernameEnvName").isBlank())
|
||||
{
|
||||
errors.add("Gmail SMTP needs an account address environment name");
|
||||
}
|
||||
if(row.getValueString("security") != null && !"STARTTLS".equals(row.getValueString("security")))
|
||||
{
|
||||
errors.add("Gmail SMTP requires STARTTLS");
|
||||
}
|
||||
}
|
||||
if("MAILGUN".equals(kind))
|
||||
{
|
||||
if(!EmailProviderSettings.validSendingDomain(row.getValueString("sendingDomain")))
|
||||
{
|
||||
errors.add("Mailgun needs a valid sending domain");
|
||||
}
|
||||
if(!List.of("US", "EU").contains(row.getValueString("region") == null ? "" : row.getValueString("region")))
|
||||
{
|
||||
errors.add("Mailgun region must be US or EU");
|
||||
}
|
||||
}
|
||||
if(("SMTP".equals(kind) || "GMAIL_SMTP".equals(kind))
|
||||
&& !new EmailProviderSettings(row, config).allowsSmtpAuth())
|
||||
{
|
||||
errors.add("SMTP sign-in needs STARTTLS or TLS");
|
||||
}
|
||||
for(String field : List.of("fromName", "fromAddress", "replyTo"))
|
||||
{
|
||||
String value = row.getValueString(field);
|
||||
if(value != null && (value.contains("\r") || value.contains("\n")))
|
||||
{
|
||||
errors.add("Provider sender and reply-to fields must not contain line breaks");
|
||||
}
|
||||
}
|
||||
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
|
||||
{
|
||||
String name = row.getValueString(field);
|
||||
if(name != null && (!EmailQBitConfig.ENV_NAME.matcher(name).matches() || !config.effectiveAllowedEnvNames(kind).contains(name)))
|
||||
{
|
||||
errors.add("Credential references must be allowed environment variable names");
|
||||
}
|
||||
}
|
||||
if("SMTP".equals(kind) && (row.getValueString("host") == null || row.getValueString("host").isBlank()
|
||||
|| row.getValueInteger("port") == null || row.getValueInteger("port") < 1 || row.getValueInteger("port") > 65535))
|
||||
{
|
||||
errors.add("SMTP needs a host and a port between 1 and 65535");
|
||||
}
|
||||
if("SES".equals(kind) && (row.getValueString("region") == null || row.getValueString("region").isBlank()))
|
||||
{
|
||||
errors.add("SES needs a region");
|
||||
}
|
||||
if("SES".equals(kind) && (row.getValueString("usernameEnvName") == null) != (row.getValueString("secretEnvName") == null))
|
||||
{
|
||||
errors.add("SES needs both credential environment names or neither for the default chain");
|
||||
}
|
||||
for(String field : List.of("maxPerMinute", "maxPerDay"))
|
||||
{
|
||||
if(row.getValueInteger(field) != null && row.getValueInteger(field) < 1)
|
||||
{
|
||||
errors.add("Rate limits must be positive or empty");
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
InternetAddress from = new InternetAddress(row.getValueString("fromAddress"), true);
|
||||
from.validate();
|
||||
String domain = row.getValueString("sendingDomain");
|
||||
if(domain != null && !domain.equalsIgnoreCase(from.getAddress().substring(from.getAddress().lastIndexOf('@') + 1)))
|
||||
{
|
||||
errors.add("From address domain must match sending domain");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
errors.add("A valid from address is required");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider validation and secret-presence display.
|
||||
*******************************************************************************/
|
||||
public static class ProviderCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate the resulting provider set, including sparse and batch writes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
if(SystemWrites.active())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
List<QRecord> resulting = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(),
|
||||
input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput insert ? insert.getTransaction()
|
||||
: input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput update ? update.getTransaction() : null));
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = effective(record, oldRecords);
|
||||
validateProvider(merged, EmailRuntime.config()).forEach(error -> record.addError(new BadInputStatusMessage(error)));
|
||||
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
|
||||
{
|
||||
String name = record.getValueString(field);
|
||||
if(name != null && !EmailQBitConfig.ENV_NAME.matcher(name).matches())
|
||||
{
|
||||
// An accidentally pasted credential must not be echoed in the failed write.
|
||||
record.setValue(field, null);
|
||||
}
|
||||
}
|
||||
resulting.removeIf(r -> Objects.equals(r.getValue("id"), record.getValue("id")));
|
||||
resulting.add(merged);
|
||||
record.setValue("defaultSlot", Boolean.TRUE.equals(merged.getValueBoolean("enabled")) && Boolean.TRUE.equals(merged.getValueBoolean("isDefault")) ? 1 : null);
|
||||
}
|
||||
long enabled = resulting.stream().filter(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled"))).count();
|
||||
long defaults = resulting.stream().filter(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")) && Boolean.TRUE.equals(r.getValueBoolean("isDefault"))).count();
|
||||
if(enabled > 0 && defaults != 1)
|
||||
{
|
||||
records.forEach(r -> r.addError(new BadInputStatusMessage("Exactly one enabled provider must be the default")));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prevent deleting the default while other enabled providers remain.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(!SystemWrites.query(EmailTables.MESSAGE, com.kingsrook.qbits.email.support.EmailStore.equal("providerId", row.getValueInteger("id")).withLimit(1), input.getTransaction()).isEmpty())
|
||||
{
|
||||
row.addError(new BadInputStatusMessage("Providers with message history cannot be deleted; disable the provider instead"));
|
||||
}
|
||||
}
|
||||
List<QRecord> remaining = new ArrayList<>(SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter(), input.getTransaction()));
|
||||
remaining.removeIf(r -> records.stream().anyMatch(d -> Objects.equals(d.getValue("id"), r.getValue("id"))));
|
||||
if(remaining.stream().anyMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")))
|
||||
&& remaining.stream().noneMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("enabled")) && Boolean.TRUE.equals(r.getValueBoolean("isDefault"))))
|
||||
{
|
||||
records.forEach(r -> r.addError(new BadInputStatusMessage("Choose another enabled default before deleting this provider")));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence only; internal queries never resolve credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
|
||||
{
|
||||
if(!SystemWrites.active())
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
for(String field : List.of("usernameEnvName", "secretEnvName", "webhookSecretEnvName"))
|
||||
{
|
||||
String value = null;
|
||||
String name = row.getValueString(field);
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
if(name != null && EmailQBitConfig.ENV_NAME.matcher(name).matches() && config.effectiveAllowedEnvNames(row.getValueString("kind")).contains(name))
|
||||
{
|
||||
try
|
||||
{
|
||||
value = config.getSecretResolver().apply(name);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// A failing resolver must never leak exception text or values.
|
||||
}
|
||||
}
|
||||
row.setValue(field + "Status", value == null || value.isBlank() ? "Not set" : "Set");
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical addresses and administrator protection for complaint removal.
|
||||
*******************************************************************************/
|
||||
public static class SuppressionCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Normalize and protect existing complaint rows from being weakened.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = effective(record, oldRecords);
|
||||
if(merged.getValue("createdDate") == null)
|
||||
{
|
||||
record.setValue("createdDate", EmailRuntime.clock().instant());
|
||||
}
|
||||
if(record.getValueString("address") != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
InternetAddress address = new InternetAddress(record.getValueString("address"), true);
|
||||
address.validate();
|
||||
record.setValue("address", address.getAddress().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("A valid suppression address is required"));
|
||||
}
|
||||
}
|
||||
if(oldRecords != null && oldRecords.isPresent() && oldRecords.get().stream().anyMatch(r -> Objects.equals(r.getValue("id"), merged.getValue("id")) && "COMPLAINT".equals(r.getValueString("reason"))))
|
||||
{
|
||||
protect(record);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only administrators remove complaint suppressions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview)
|
||||
{
|
||||
records.stream().filter(r -> "COMPLAINT".equals(r.getValueString("reason"))).forEach(SuppressionCustomizer::protect);
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the complaint permission even if core enforcement is disabled.
|
||||
*******************************************************************************/
|
||||
private static void protect(QRecord record)
|
||||
{
|
||||
if(!SystemWrites.active() && !EmailPermissionChecker.has(EmailPermissions.ADMIN))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Changing a complaint suppression needs email.admin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Link application records through the standard dynamic record-link adornment.
|
||||
*******************************************************************************/
|
||||
public static class MessageLinksCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Supply only the target table name; no linked record content is loaded.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
|
||||
{
|
||||
if(input.getShouldGenerateDisplayValues())
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
String table = row.getValueString("linkedTableName");
|
||||
if(table != null && row.getValue("linkedRecordId") != null
|
||||
&& com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable(table) != null)
|
||||
{
|
||||
row.setDisplayValue("linkedRecordId:toRecordFromTableDynamic", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard visible tables, with system-owned messages and attempt claims.
|
||||
*******************************************************************************/
|
||||
public final class EmailTables
|
||||
{
|
||||
public static final String PROVIDER = "email_provider";
|
||||
public static final String MESSAGE = "email_message";
|
||||
public static final String ATTEMPT = "email_message_attempt";
|
||||
public static final String TEMPLATE = "email_template";
|
||||
public static final String VERSION = "email_template_version";
|
||||
public static final String BRAND = "email_brand";
|
||||
public static final String SUPPRESSION = "email_suppression";
|
||||
public static final List<String> ALL = List.of(PROVIDER, MESSAGE, ATTEMPT, TEMPLATE, VERSION, BRAND, SUPPRESSION);
|
||||
public static final List<String> MAIN = List.of(MESSAGE, TEMPLATE, BRAND, SUPPRESSION, PROVIDER);
|
||||
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT);
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailTables()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared permission base and custom action checker.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("email")
|
||||
.withCustomPermissionChecker(new QCodeReference(EmailPermissionChecker.class)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All enum and table possible-value sources.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> result = new ArrayList<>();
|
||||
result.add(QPossibleValueSource.newForEnum("email_provider_kind", EmailEnums.ProviderKind.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_security", EmailEnums.Security.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_health_status", EmailEnums.HealthStatus.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_message_status", EmailEnums.MessageStatus.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_attempt_outcome", EmailEnums.AttemptOutcome.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_template_kind", EmailEnums.TemplateKind.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_version_status", EmailEnums.VersionStatus.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_suppression_scope", EmailEnums.SuppressionScope.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_suppression_reason", EmailEnums.SuppressionReason.values()));
|
||||
result.add(QPossibleValueSource.newForEnum("email_category", EmailCategory.values()));
|
||||
ALL.forEach(name -> result.add(QPossibleValueSource.newForTable(name)));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The seven phase (a) tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(EmailQBitConfig config)
|
||||
{
|
||||
List<QTableMetaData> tables = new ArrayList<>();
|
||||
tables.add(provider(config));
|
||||
tables.add(message(config));
|
||||
tables.add(messageattempt(config));
|
||||
tables.add(template(config));
|
||||
tables.add(templateversion(config));
|
||||
tables.add(brand(config));
|
||||
tables.add(suppression(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
for(QFieldMetaData field : config.getSecurityFields())
|
||||
{
|
||||
table.addField(field.clone());
|
||||
table.getSections().get(0).getFieldNames().add(field.getName());
|
||||
}
|
||||
config.getRecordSecurityLocks().forEach(table::withRecordSecurityLock);
|
||||
if(SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
table.getFields().values().forEach(f -> f.setIsEditable(false));
|
||||
}
|
||||
table.getFields().values().forEach(f -> f.setBackendName(QInstanceEnricher.inferBackendName(f.getName())));
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common fields and record label.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(EmailQBitConfig config, String name, String label)
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id").withIcon(new QIcon("mail")).withPermissionRules(permissionRules())
|
||||
.withRecordLabelFormatAndFields("%s", "id")
|
||||
.withField(field("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(field("createDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field with a plain English label.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
String words = name.replaceAll("([a-z])([A-Z])", "$1 $2");
|
||||
return (new QFieldMetaData(name, type).withLabel(Character.toUpperCase(words.charAt(0)) + words.substring(1)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_provider.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData provider(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, PROVIDER, "Provider")
|
||||
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("label", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_provider_kind").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("isDefault", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("fromAddress", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
|
||||
.withField(field("fromName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("replyTo", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
|
||||
.withField(field("sendingDomain", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("host", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("port", QFieldType.INTEGER))
|
||||
.withField(field("security", QFieldType.STRING).withPossibleValueSourceName("email_security").withDefaultValue("NONE").withMaxLength(500))
|
||||
.withField(field("region", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("usernameEnvName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("secretEnvName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("webhookSecretEnvName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("snsTopicArn", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("configurationSet", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("messageStream", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("maxPerMinute", QFieldType.INTEGER))
|
||||
.withField(field("maxPerDay", QFieldType.INTEGER))
|
||||
.withField(field("trackOpens", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("trackClicks", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("phiAllowed", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("healthStatus", QFieldType.STRING).withPossibleValueSourceName("email_health_status").withDefaultValue("HEALTHY").withMaxLength(500))
|
||||
.withField(field("lastSuccessDate", QFieldType.DATE_TIME))
|
||||
.withField(field("lastFailureDate", QFieldType.DATE_TIME))
|
||||
.withField(field("lastFailureMessage", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("dispatchLock", QFieldType.INTEGER).withDefaultValue(0).withIsHidden(true).withIsEditable(false))
|
||||
.withField(field("defaultSlot", QFieldType.INTEGER).withIsHidden(true).withIsEditable(false));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
table.withUniqueKey(new UniqueKey("defaultSlot"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "label", "kind", "enabled", "isDefault"));
|
||||
table.withSection(SectionFactory.defaultT2("fromAddress", "fromName", "replyTo", "sendingDomain", "host", "port", "security", "region", "usernameEnvName", "secretEnvName", "webhookSecretEnvName", "snsTopicArn", "configurationSet", "messageStream", "maxPerMinute", "maxPerDay", "trackOpens", "trackClicks", "phiAllowed", "healthStatus", "lastSuccessDate", "lastFailureDate", "lastFailureMessage"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Messages").withWidgetName(PROVIDER + "_children"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_message.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData message(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, MESSAGE, "Message")
|
||||
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_message_status").withMaxLength(500))
|
||||
.withField(field("toAddresses", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("ccAddresses", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("bccAddresses", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("fromAddress", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
|
||||
.withField(field("replyTo", QFieldType.STRING).withIsSensitive(true).withMaxLength(500))
|
||||
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE))
|
||||
.withField(field("templateVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION))
|
||||
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
|
||||
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
|
||||
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500))
|
||||
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("htmlBody", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("textBody", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("bodySha256", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
|
||||
.withField(field("providerMessageId", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("attemptCount", QFieldType.INTEGER).withDefaultValue(0))
|
||||
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
|
||||
.withField(field("leaseExpiresDate", QFieldType.DATE_TIME))
|
||||
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("finishedDate", QFieldType.DATE_TIME))
|
||||
.withField(field("sentDate", QFieldType.DATE_TIME))
|
||||
.withField(field("deliveredDate", QFieldType.DATE_TIME))
|
||||
.withField(field("deadLetterDate", QFieldType.DATE_TIME))
|
||||
.withField(field("linkedTableName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("linkedRecordId", QFieldType.STRING).withMaxLength(500)
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.LINK).withValue(AdornmentType.LinkValues.TO_RECORD_FROM_TABLE_DYNAMIC, true)))
|
||||
.withField(field("senderQbit", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("senderProcess", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("suppressedAddresses", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("sendAfter", QFieldType.DATE_TIME));
|
||||
table.withUniqueKey(new UniqueKey("idempotencyKey"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "status", "toAddresses", "ccAddresses", "bccAddresses", "fromAddress"));
|
||||
table.withSection(SectionFactory.defaultT2("replyTo", "subject", "templateId", "templateVersionId", "brandId", "category", "locale", "variablesSnapshot", "htmlBody", "textBody", "bodySha256", "idempotencyKey", "providerId", "providerMessageId", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastError", "finishedDate", "sentDate", "deliveredDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderQbit", "senderProcess", "suppressedAddresses", "sendAfter"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Attempts").withWidgetName(MESSAGE + "_children"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_message_attempt.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData messageattempt(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ATTEMPT, "Message Attempt")
|
||||
.withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE).withIsRequired(true))
|
||||
.withField(field("attemptNumber", QFieldType.INTEGER).withIsRequired(true))
|
||||
.withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("email_attempt_outcome").withMaxLength(500))
|
||||
.withField(field("providerId", QFieldType.INTEGER).withPossibleValueSourceName(PROVIDER))
|
||||
.withField(field("claimedBy", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("startDate", QFieldType.DATE_TIME))
|
||||
.withField(field("endDate", QFieldType.DATE_TIME))
|
||||
.withField(field("leaseExpiresDate", QFieldType.DATE_TIME))
|
||||
.withField(field("durationMillis", QFieldType.INTEGER))
|
||||
.withField(field("error", QFieldType.STRING).withMaxLength(500));
|
||||
table.withUniqueKey(new UniqueKey("messageId", "attemptNumber"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "messageId", "attemptNumber", "outcome", "providerId", "claimedBy"));
|
||||
table.withSection(SectionFactory.defaultT2("startDate", "endDate", "leaseExpiresDate", "durationMillis", "error"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_template.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData template(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, TEMPLATE, "Template")
|
||||
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("label", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("category", QFieldType.STRING).withPossibleValueSourceName("email_category").withMaxLength(500))
|
||||
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("email_template_kind").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("brandId", QFieldType.INTEGER).withPossibleValueSourceName(BRAND))
|
||||
.withField(field("activeVersionId", QFieldType.INTEGER).withPossibleValueSourceName(VERSION))
|
||||
.withField(field("phiApproved", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("phiApprovedBy", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("phiApprovedDate", QFieldType.DATE_TIME))
|
||||
.withField(field("editPermissionName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("ownerQbit", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("description", QFieldType.TEXT));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "label", "category", "kind", "brandId"));
|
||||
table.withSection(SectionFactory.defaultT2("activeVersionId", "phiApproved", "phiApprovedBy", "phiApprovedDate", "editPermissionName", "ownerQbit", "description"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2("children", new QIcon("list")).withLabel("Versions").withWidgetName(TEMPLATE + "_children"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_template_version.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData templateversion(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, VERSION, "Template Version")
|
||||
.withField(field("templateId", QFieldType.INTEGER).withPossibleValueSourceName(TEMPLATE))
|
||||
.withField(field("versionNumber", QFieldType.INTEGER))
|
||||
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("email_version_status").withMaxLength(500))
|
||||
.withField(field("locale", QFieldType.STRING).withDefaultValue("en").withMaxLength(500))
|
||||
.withField(field("subject", QFieldType.TEXT).withIsSensitive(true))
|
||||
.withField(field("preheader", QFieldType.TEXT))
|
||||
.withField(field("bodySource", QFieldType.TEXT))
|
||||
.withField(field("textSource", QFieldType.TEXT))
|
||||
.withField(field("compiledHtml", QFieldType.TEXT))
|
||||
.withField(field("variablesSchema", QFieldType.TEXT))
|
||||
.withField(field("sampleVariables", QFieldType.TEXT))
|
||||
.withField(field("createdBy", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("activatedDate", QFieldType.DATE_TIME));
|
||||
table.withUniqueKey(new UniqueKey("templateId", "versionNumber", "locale"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "templateId", "versionNumber", "status", "locale", "subject"));
|
||||
table.withSection(SectionFactory.defaultT2("preheader", "bodySource", "textSource", "compiledHtml", "variablesSchema", "sampleVariables", "createdBy", "activatedDate"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_brand.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData brand(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, BRAND, "Brand")
|
||||
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("label", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("logoUrl", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("logoDarkUrl", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("logoAlt", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("primaryColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("textColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("backgroundColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("darkBackgroundColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("darkTextColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("buttonTextColor", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("footerMarkdown", QFieldType.TEXT))
|
||||
.withField(field("postalAddress", QFieldType.TEXT))
|
||||
.withField(field("socialLinks", QFieldType.TEXT))
|
||||
.withField(field("siteUrl", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("isDefault", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("appName", QFieldType.STRING).withMaxLength(500));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "label", "logoUrl", "logoDarkUrl", "logoAlt"));
|
||||
table.withSection(SectionFactory.defaultT2("primaryColor", "textColor", "backgroundColor", "darkBackgroundColor", "darkTextColor", "buttonTextColor", "footerMarkdown", "postalAddress", "socialLinks", "siteUrl", "isDefault", "appName"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for email_suppression.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData suppression(EmailQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, SUPPRESSION, "Suppression")
|
||||
.withField(field("address", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("scope", QFieldType.STRING).withPossibleValueSourceName("email_suppression_scope").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("reason", QFieldType.STRING).withPossibleValueSourceName("email_suppression_reason").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("sourceMessageId", QFieldType.INTEGER))
|
||||
.withField(field("sourceEventId", QFieldType.INTEGER))
|
||||
.withField(field("note", QFieldType.TEXT))
|
||||
.withField(field("createdDate", QFieldType.DATE_TIME))
|
||||
.withField(field("expiresDate", QFieldType.DATE_TIME));
|
||||
table.withUniqueKey(new UniqueKey("address", "scope"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "address", "scope", "reason", "sourceMessageId", "sourceEventId"));
|
||||
table.withSection(SectionFactory.defaultT2("note", "createdDate", "expiresDate"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Relations used by child lists.
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> joins()
|
||||
{
|
||||
return (List.of(join(PROVIDER, MESSAGE, "providerId"), join(MESSAGE, ATTEMPT, "messageId"), join(TEMPLATE, VERSION, "templateId")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent to child relation.
|
||||
*******************************************************************************/
|
||||
private static QJoinMetaData join(String parent, String child, String field)
|
||||
{
|
||||
return (new QJoinMetaData().withName(parent + "_join_" + child).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(parent).withRightTable(child).withJoinOn(new JoinOn("id", field)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Child lists live on the parent record, never in navigation.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> childWidgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
for(QJoinMetaData join : joins())
|
||||
{
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getLeftTable() + "_children")
|
||||
.withLabel(join.getRightTable().equals(ATTEMPT) ? "Attempts" : join.getRightTable().equals(VERSION) ? "Versions" : "Messages")
|
||||
.withMaxRows(50).getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
}
|
||||
return (widgets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import org.commonmark.ext.autolink.AutolinkExtension;
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal part 1 layout. Markdown raw HTML and unsafe URL schemes are disabled.
|
||||
*******************************************************************************/
|
||||
public class AdHocEmailRenderer implements EmailRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render markdown, HTML, or plain text into both alternatives.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Rendered render(EmailRequest request)
|
||||
{
|
||||
String html = request.getHtmlBody();
|
||||
if(html == null)
|
||||
{
|
||||
if(request.getMarkdownBody() != null)
|
||||
{
|
||||
var extensions = List.of(TablesExtension.create(), AutolinkExtension.create());
|
||||
html = HtmlRenderer.builder().escapeHtml(true).sanitizeUrls(true).extensions(extensions).build()
|
||||
.render(Parser.builder().extensions(extensions).build().parse(request.getMarkdownBody()));
|
||||
}
|
||||
else
|
||||
{
|
||||
html = "<pre>" + escape(request.getTextBody()) + "</pre>";
|
||||
}
|
||||
}
|
||||
String text = request.getTextBody();
|
||||
if(text == null)
|
||||
{
|
||||
text = html.replaceAll("(?is)<(script|style)[^>]*>.*?</\\1>", "")
|
||||
.replaceAll("(?i)<br\\s*/?>|</(p|div|li|h[1-6]|tr)>", "\n")
|
||||
.replaceAll("<[^>]+>", "").replace("<", "<").replace(">", ">").replace(""", "\"")
|
||||
.replace("'", "'").replace(" ", " ").replace("&", "&").strip();
|
||||
}
|
||||
return (new Rendered("<!doctype html><html><body><main>" + html + "</main></body></html>", text));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape plain text for the fallback HTML part.
|
||||
*******************************************************************************/
|
||||
private static String escape(String text)
|
||||
{
|
||||
return (text == null ? "" : text.replace("&", "&").replace("<", "<").replace(">", ">"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Purpose of a message; transactional and security messages cannot be opted out.
|
||||
*******************************************************************************/
|
||||
public enum EmailCategory implements PossibleValueEnum<String>
|
||||
{
|
||||
TRANSACTIONAL,
|
||||
NOTIFICATION,
|
||||
MARKETING,
|
||||
SECURITY;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable database value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (name().charAt(0) + name().substring(1).toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replaceable rendering seam for the versioned template and brand renderer.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface EmailRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render already validated ad-hoc content.
|
||||
*******************************************************************************/
|
||||
Rendered render(EmailRequest request);
|
||||
|
||||
/*******************************************************************************
|
||||
** Rendered alternatives persisted once for all attempts.
|
||||
*******************************************************************************/
|
||||
record Rendered(String html, String text)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Synchronous enqueue request. Templates and classified variables arrive in part 2.
|
||||
*******************************************************************************/
|
||||
public class EmailRequest
|
||||
{
|
||||
private List<String> to = new ArrayList<>();
|
||||
private List<String> cc = new ArrayList<>();
|
||||
private List<String> bcc = new ArrayList<>();
|
||||
private String from;
|
||||
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 EmailCategory category = EmailCategory.TRANSACTIONAL;
|
||||
private String idempotencyKey;
|
||||
private Instant sendAfter;
|
||||
private String linkedTableName;
|
||||
private String linkedRecordId;
|
||||
private String senderQbit;
|
||||
private String senderProcess;
|
||||
private QBackendTransaction transaction;
|
||||
private String transactionBackendName;
|
||||
private Map<String, Serializable> variables = new LinkedHashMap<>();
|
||||
private Map<String, Serializable> securityValues = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Append to recipients.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTo(String... addresses)
|
||||
{
|
||||
to.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Append cc recipients.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withCc(String... addresses)
|
||||
{
|
||||
cc.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Append bcc recipients.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withBcc(String... addresses)
|
||||
{
|
||||
bcc.addAll(List.of(addresses));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Link the message to an application record.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withLinkedRecord(String table, Object id)
|
||||
{
|
||||
linkedTableName = table;
|
||||
linkedRecordId = id == null ? null : String.valueOf(id);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Identify the producing qbit and process.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSender(String qbit, String process)
|
||||
{
|
||||
senderQbit = qbit;
|
||||
senderProcess = process;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare the backend of the caller transaction; QBackendTransaction does not expose it.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTransaction(QBackendTransaction transaction, String backendName)
|
||||
{
|
||||
this.transaction = transaction;
|
||||
this.transactionBackendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept template variables as a seam; part 1 refuses variable-bearing requests.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withVariable(String name, Serializable value)
|
||||
{
|
||||
variables.put(name, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept a record reference without reading or snapshotting the record.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withRecordVariable(String name, String table, Serializable id)
|
||||
{
|
||||
variables.put(name, new LinkedHashMap<>(Map.of("table", table, "id", id)));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for to.
|
||||
*******************************************************************************/
|
||||
public List<String> getTo()
|
||||
{
|
||||
return (to);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for to.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTo(List<String> to)
|
||||
{
|
||||
this.to = to;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cc.
|
||||
*******************************************************************************/
|
||||
public List<String> getCc()
|
||||
{
|
||||
return (cc);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cc.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withCc(List<String> cc)
|
||||
{
|
||||
this.cc = cc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bcc.
|
||||
*******************************************************************************/
|
||||
public List<String> getBcc()
|
||||
{
|
||||
return (bcc);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bcc.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withBcc(List<String> bcc)
|
||||
{
|
||||
this.bcc = bcc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for from.
|
||||
*******************************************************************************/
|
||||
public String getFrom()
|
||||
{
|
||||
return (from);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for from.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withFrom(String from)
|
||||
{
|
||||
this.from = from;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for replyTo.
|
||||
*******************************************************************************/
|
||||
public String getReplyTo()
|
||||
{
|
||||
return (replyTo);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for replyTo.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withReplyTo(String replyTo)
|
||||
{
|
||||
this.replyTo = replyTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subject.
|
||||
*******************************************************************************/
|
||||
public String getSubject()
|
||||
{
|
||||
return (subject);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subject.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSubject(String subject)
|
||||
{
|
||||
this.subject = subject;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for markdownBody.
|
||||
*******************************************************************************/
|
||||
public String getMarkdownBody()
|
||||
{
|
||||
return (markdownBody);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for markdownBody.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withMarkdownBody(String markdownBody)
|
||||
{
|
||||
this.markdownBody = markdownBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public String getHtmlBody()
|
||||
{
|
||||
return (htmlBody);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withHtmlBody(String htmlBody)
|
||||
{
|
||||
this.htmlBody = htmlBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for textBody.
|
||||
*******************************************************************************/
|
||||
public String getTextBody()
|
||||
{
|
||||
return (textBody);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for textBody.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTextBody(String textBody)
|
||||
{
|
||||
this.textBody = textBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerName.
|
||||
*******************************************************************************/
|
||||
public String getProviderName()
|
||||
{
|
||||
return (providerName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerName.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withProviderName(String providerName)
|
||||
{
|
||||
this.providerName = providerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for templateName.
|
||||
*******************************************************************************/
|
||||
public String getTemplateName()
|
||||
{
|
||||
return (templateName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for templateName.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTemplateName(String templateName)
|
||||
{
|
||||
this.templateName = templateName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for brandName.
|
||||
*******************************************************************************/
|
||||
public String getBrandName()
|
||||
{
|
||||
return (brandName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for brandName.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withBrandName(String brandName)
|
||||
{
|
||||
this.brandName = brandName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for locale.
|
||||
*******************************************************************************/
|
||||
public String getLocale()
|
||||
{
|
||||
return (locale);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for locale.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withLocale(String locale)
|
||||
{
|
||||
this.locale = locale;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category.
|
||||
*******************************************************************************/
|
||||
public EmailCategory getCategory()
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withCategory(EmailCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return (idempotencyKey);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sendAfter.
|
||||
*******************************************************************************/
|
||||
public Instant getSendAfter()
|
||||
{
|
||||
return (sendAfter);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sendAfter.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSendAfter(Instant sendAfter)
|
||||
{
|
||||
this.sendAfter = sendAfter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkedTableName.
|
||||
*******************************************************************************/
|
||||
public String getLinkedTableName()
|
||||
{
|
||||
return (linkedTableName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkedTableName.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withLinkedTableName(String linkedTableName)
|
||||
{
|
||||
this.linkedTableName = linkedTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkedRecordId.
|
||||
*******************************************************************************/
|
||||
public String getLinkedRecordId()
|
||||
{
|
||||
return (linkedRecordId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkedRecordId.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withLinkedRecordId(String linkedRecordId)
|
||||
{
|
||||
this.linkedRecordId = linkedRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for senderQbit.
|
||||
*******************************************************************************/
|
||||
public String getSenderQbit()
|
||||
{
|
||||
return (senderQbit);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for senderQbit.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSenderQbit(String senderQbit)
|
||||
{
|
||||
this.senderQbit = senderQbit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for senderProcess.
|
||||
*******************************************************************************/
|
||||
public String getSenderProcess()
|
||||
{
|
||||
return (senderProcess);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for senderProcess.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSenderProcess(String senderProcess)
|
||||
{
|
||||
this.senderProcess = senderProcess;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for transaction.
|
||||
*******************************************************************************/
|
||||
public QBackendTransaction getTransaction()
|
||||
{
|
||||
return (transaction);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for transaction.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTransaction(QBackendTransaction transaction)
|
||||
{
|
||||
this.transaction = transaction;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for transactionBackendName.
|
||||
*******************************************************************************/
|
||||
public String getTransactionBackendName()
|
||||
{
|
||||
return (transactionBackendName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for transactionBackendName.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withTransactionBackendName(String transactionBackendName)
|
||||
{
|
||||
this.transactionBackendName = transactionBackendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for variables.
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getVariables()
|
||||
{
|
||||
return (variables);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for variables.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withVariables(Map<String, Serializable> variables)
|
||||
{
|
||||
this.variables = variables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for securityValues.
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getSecurityValues()
|
||||
{
|
||||
return (securityValues);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for securityValues.
|
||||
*******************************************************************************/
|
||||
public EmailRequest withSecurityValues(Map<String, Serializable> securityValues)
|
||||
{
|
||||
this.securityValues = securityValues;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue result. Cross-backend deferred inserts initially have no message id.
|
||||
*******************************************************************************/
|
||||
public record EmailSendResult(Integer messageId, Status status, List<String> reasons)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Enqueue outcomes.
|
||||
*******************************************************************************/
|
||||
public enum Status
|
||||
{
|
||||
QUEUED,
|
||||
DUPLICATE,
|
||||
SUPPRESSED,
|
||||
REFUSED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* 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.outbox;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
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.permissions.EmailPermissionChecker;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.data.QRecord;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validated transactional outbox API. This class never performs network delivery.
|
||||
*******************************************************************************/
|
||||
public final class EmailService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailService()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue with the part 1 renderer.
|
||||
*******************************************************************************/
|
||||
public static EmailSendResult send(EmailRequest request) throws QException
|
||||
{
|
||||
return (send(request, new AdHocEmailRenderer()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue with a renderer supplied by the host or a later phase.
|
||||
*******************************************************************************/
|
||||
public static EmailSendResult send(EmailRequest request, EmailRenderer renderer) throws QException
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
if(config == null)
|
||||
{
|
||||
return (refused("Email qbit is not installed"));
|
||||
}
|
||||
if(CorePermissionEnforcer.shouldEnforce(new SendMessageInput()) && !EmailPermissionChecker.has(EmailPermissions.SEND))
|
||||
{
|
||||
return (refused("Sending email needs email.send"));
|
||||
}
|
||||
if(request == null)
|
||||
{
|
||||
return (refused("An email request is required"));
|
||||
}
|
||||
if(request.getTemplateName() != null)
|
||||
{
|
||||
return (refused("templates arrive in part 2"));
|
||||
}
|
||||
if(!request.getVariables().isEmpty() || request.getBrandName() != null)
|
||||
{
|
||||
return (refused("Template variables and branding arrive in part 2"));
|
||||
}
|
||||
if(request.getSubject() == null || request.getSubject().contains("\r") || request.getSubject().contains("\n")
|
||||
|| (request.getMarkdownBody() == null && request.getHtmlBody() == null && request.getTextBody() == null))
|
||||
{
|
||||
return (refused("A subject and message body are required; subject must be one line"));
|
||||
}
|
||||
EmailCategory category = request.getCategory() == null ? EmailCategory.TRANSACTIONAL : request.getCategory();
|
||||
QBackendTransaction transaction = request.getTransaction();
|
||||
boolean sameBackend = transaction == null || request.getTransactionBackendName() == null || config.getBackendName().equals(request.getTransactionBackendName());
|
||||
QBackendTransaction queryTransaction = sameBackend ? transaction : null;
|
||||
QRecord duplicate = duplicate(request.getIdempotencyKey(), queryTransaction);
|
||||
if(duplicate != null)
|
||||
{
|
||||
return (new EmailSendResult(duplicate.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
|
||||
}
|
||||
String providerName = request.getProviderName() == null ? config.getDefaultProviderName() : request.getProviderName();
|
||||
QQueryFilter providerFilter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true));
|
||||
providerFilter.withCriteria(new QFilterCriteria(providerName == null ? "isDefault" : "name", QCriteriaOperator.EQUALS, providerName == null ? true : providerName));
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, providerFilter, queryTransaction);
|
||||
if(providers.size() != 1)
|
||||
{
|
||||
return (refused("An enabled named or default provider is required"));
|
||||
}
|
||||
QRecord provider = providers.get(0);
|
||||
List<String> providerErrors = EmailTableCustomizers.validateProvider(provider, config);
|
||||
if(!providerErrors.isEmpty())
|
||||
{
|
||||
return (new EmailSendResult(null, EmailSendResult.Status.REFUSED, providerErrors));
|
||||
}
|
||||
List<String> to;
|
||||
List<String> cc;
|
||||
List<String> bcc;
|
||||
String from;
|
||||
String replyTo;
|
||||
try
|
||||
{
|
||||
to = addresses(request.getTo());
|
||||
cc = addresses(request.getCc());
|
||||
bcc = addresses(request.getBcc());
|
||||
String sender = request.getFrom() == null ? provider.getValueString("fromAddress") : request.getFrom();
|
||||
from = single(sender);
|
||||
if(request.getFrom() == null && provider.getValueString("fromName") != null)
|
||||
{
|
||||
from = new InternetAddress(new InternetAddress(from).getAddress(), provider.getValueString("fromName"), "UTF-8").toString();
|
||||
}
|
||||
String reply = request.getReplyTo() == null ? provider.getValueString("replyTo") : request.getReplyTo();
|
||||
replyTo = reply == null ? null : single(reply);
|
||||
String domain = provider.getValueString("sendingDomain");
|
||||
String address = new InternetAddress(from).getAddress();
|
||||
if(domain != null && !domain.equalsIgnoreCase(address.substring(address.lastIndexOf('@') + 1)))
|
||||
{
|
||||
return (refused("From address domain must match sending domain"));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (refused("One or more email addresses are invalid"));
|
||||
}
|
||||
if(to.isEmpty() && cc.isEmpty() && bcc.isEmpty())
|
||||
{
|
||||
return (refused("At least one recipient is required"));
|
||||
}
|
||||
Set<String> blocked = suppressed(category, queryTransaction);
|
||||
List<String> dropped = new ArrayList<>();
|
||||
for(List<String> addresses : List.of(to, cc, bcc))
|
||||
{
|
||||
addresses.removeIf(address ->
|
||||
{
|
||||
try
|
||||
{
|
||||
if(blocked.contains(new InternetAddress(address).getAddress().toLowerCase(Locale.ROOT)))
|
||||
{
|
||||
dropped.add(address);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Addresses were validated above.
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
}
|
||||
boolean allSuppressed = to.isEmpty() && cc.isEmpty() && bcc.isEmpty();
|
||||
EmailRenderer.Rendered rendered = renderer.render(request);
|
||||
Instant now = EmailRuntime.clock().instant();
|
||||
QRecord row = new QRecord().withValue("providerId", provider.getValue("id"))
|
||||
.withValue("status", allSuppressed ? "SUPPRESSED" : "QUEUED").withValue("finishedDate", allSuppressed ? now : null).withValue("attemptCount", 0)
|
||||
.withValue("toAddresses", String.join(",", to)).withValue("ccAddresses", String.join(",", cc)).withValue("bccAddresses", String.join(",", bcc))
|
||||
.withValue("fromAddress", from).withValue("replyTo", replyTo).withValue("subject", request.getSubject())
|
||||
.withValue("htmlBody", rendered.html()).withValue("textBody", rendered.text()).withValue("bodySha256", digest(rendered))
|
||||
.withValue("category", category.name()).withValue("locale", request.getLocale()).withValue("idempotencyKey", request.getIdempotencyKey())
|
||||
.withValue("linkedTableName", request.getLinkedTableName()).withValue("linkedRecordId", request.getLinkedRecordId())
|
||||
.withValue("senderQbit", request.getSenderQbit()).withValue("senderProcess", request.getSenderProcess())
|
||||
.withValue("sendAfter", request.getSendAfter()).withValue("nextAttemptDate", request.getSendAfter() == null ? now : request.getSendAfter())
|
||||
.withValue("suppressedAddresses", String.join(",", dropped));
|
||||
for(var field : config.getSecurityFields())
|
||||
{
|
||||
row.setValue(field.getName(), request.getSecurityValues().get(field.getName()));
|
||||
}
|
||||
if(transaction != null && !sameBackend)
|
||||
{
|
||||
transaction.addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
if(insert(row, null).status() == EmailSendResult.Status.REFUSED)
|
||||
{
|
||||
throw (new QException("Email after-commit enqueue failed"));
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
// Core logs callback failures; propagate only a sanitized message.
|
||||
throw (new IllegalStateException("Email after-commit enqueue failed"));
|
||||
}
|
||||
});
|
||||
return (new EmailSendResult(null, allSuppressed ? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED,
|
||||
List.of("Enqueue deferred until caller transaction commits")));
|
||||
}
|
||||
return (insert(row, transaction));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert once; the database unique key arbitrates concurrent requests.
|
||||
*******************************************************************************/
|
||||
private static EmailSendResult insert(QRecord row, QBackendTransaction caller) throws QException
|
||||
{
|
||||
if(caller != null)
|
||||
{
|
||||
return (insertInTransaction(row, caller));
|
||||
}
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
EmailSendResult result = insertInTransaction(row, transaction);
|
||||
if(result.status() == EmailSendResult.Status.REFUSED)
|
||||
{
|
||||
transaction.rollback();
|
||||
}
|
||||
else
|
||||
{
|
||||
transaction.commit();
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
QRecord existing = duplicate(row.getValueString("idempotencyKey"), null);
|
||||
return (existing == null ? refused("Could not queue the email message") : new EmailSendResult(existing.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert using the caller transaction, returning only safe diagnostics.
|
||||
*******************************************************************************/
|
||||
private static EmailSendResult insertInTransaction(QRecord row, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
// A shared provider-row gate prevents a duplicate-key failure from aborting
|
||||
// a caller-owned SQL transaction. The unique index remains the final arbiter.
|
||||
if(row.getValueString("idempotencyKey") != null)
|
||||
{
|
||||
List<QRecord> providers = SystemWrites.query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id")).withLimit(1), transaction);
|
||||
if(!providers.isEmpty())
|
||||
{
|
||||
EmailStore.lockProvider(providers.get(0).getValueInteger("id"), transaction);
|
||||
}
|
||||
}
|
||||
QRecord existing = duplicate(row.getValueString("idempotencyKey"), transaction);
|
||||
if(existing != null)
|
||||
{
|
||||
return (new EmailSendResult(existing.getValueInteger("id"), EmailSendResult.Status.DUPLICATE, List.of()));
|
||||
}
|
||||
QRecord inserted;
|
||||
try
|
||||
{
|
||||
inserted = SystemWrites.insert(EmailTables.MESSAGE, List.of(row), transaction).getRecords().get(0);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (refused("Could not queue the email message"));
|
||||
}
|
||||
if(inserted.getErrors() != null && !inserted.getErrors().isEmpty())
|
||||
{
|
||||
return (refused("Could not queue the email message"));
|
||||
}
|
||||
return (new EmailSendResult(inserted.getValueInteger("id"), "SUPPRESSED".equals(row.getValueString("status"))
|
||||
? EmailSendResult.Status.SUPPRESSED : EmailSendResult.Status.QUEUED, List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up an idempotency key in the appropriate transaction.
|
||||
*******************************************************************************/
|
||||
private static QRecord duplicate(String key, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(key == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
List<QRecord> rows = SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("idempotencyKey", QCriteriaOperator.EQUALS, key)), transaction);
|
||||
return (rows.isEmpty() ? null : rows.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppression matching; opt-outs never suppress transactional or security mail.
|
||||
*******************************************************************************/
|
||||
private static Set<String> suppressed(EmailCategory category, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
boolean optOut = category == EmailCategory.NOTIFICATION || category == EmailCategory.MARKETING;
|
||||
for(QRecord row : SystemWrites.query(EmailTables.SUPPRESSION, new QQueryFilter(), transaction))
|
||||
{
|
||||
Instant expires = row.getValueInstant("expiresDate");
|
||||
if(expires != null && !expires.isAfter(EmailRuntime.clock().instant()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!optOut && "UNSUBSCRIBE".equals(row.getValueString("reason")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("ALL".equals(row.getValueString("scope")) || (optOut && category.name().equals(row.getValueString("scope"))))
|
||||
{
|
||||
result.add(row.getValueString("address").toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict address parsing, rejecting header injection and local-only addresses.
|
||||
*******************************************************************************/
|
||||
public static List<String> addresses(List<String> input) throws Exception
|
||||
{
|
||||
List<String> result = new ArrayList<>();
|
||||
for(String value : input)
|
||||
{
|
||||
if(value == null || value.contains("\r") || value.contains("\n"))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid address"));
|
||||
}
|
||||
for(InternetAddress address : InternetAddress.parse(value, true))
|
||||
{
|
||||
address.validate();
|
||||
if(address.isGroup() || !address.getAddress().contains("@"))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid address"));
|
||||
}
|
||||
result.add(address.toString());
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One sender or reply-to mailbox.
|
||||
*******************************************************************************/
|
||||
private static String single(String value) throws Exception
|
||||
{
|
||||
List<String> addresses = addresses(List.of(value));
|
||||
if(addresses.size() != 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("One address required"));
|
||||
}
|
||||
return (addresses.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep a digest after body retention clears both alternatives.
|
||||
*******************************************************************************/
|
||||
private static String digest(EmailRenderer.Rendered rendered)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((rendered.html() + "\u0000" + rendered.text()).getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refusal with no stored row.
|
||||
*******************************************************************************/
|
||||
private static EmailSendResult refused(String reason)
|
||||
{
|
||||
return (new EmailSendResult(null, EmailSendResult.Status.REFUSED, List.of(reason)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maps every action on the qbit's tables, processes, widgets and app to one
|
||||
** of {@code email.view}, {@code email.manage} or {@code email.admin}
|
||||
** (admin implies manage, manage implies view).
|
||||
**
|
||||
** <p>Deliveries and attempts are written only by the qbit (its own writes run
|
||||
** in an authorized scope); people and the API can read them and act on them
|
||||
** through the processes.</p>
|
||||
*******************************************************************************/
|
||||
public class EmailPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check, or throw.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) throws QPermissionDeniedException
|
||||
{
|
||||
String needed = required(actionInput, metaData);
|
||||
if(needed == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Email messages and attempts are written only by the email qbit. Use Send Now, Resend or Cancel."));
|
||||
}
|
||||
if(!has(needed))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission an action needs (null: nobody may do it directly).
|
||||
*******************************************************************************/
|
||||
static String required(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData)
|
||||
{
|
||||
boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput;
|
||||
if(metaData instanceof QTableMetaData table)
|
||||
{
|
||||
if(EmailTables.PROVIDER.equals(table.getName()))
|
||||
{
|
||||
return (EmailPermissions.ADMIN);
|
||||
}
|
||||
if(!write)
|
||||
{
|
||||
return (EmailPermissions.VIEW);
|
||||
}
|
||||
if(EmailTables.SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(EmailTables.PROVIDER.equals(table.getName()))
|
||||
{
|
||||
return (EmailPermissions.ADMIN);
|
||||
}
|
||||
return (EmailPermissions.MANAGE);
|
||||
}
|
||||
if(metaData instanceof QProcessMetaData process)
|
||||
{
|
||||
return (EmailProcesses.permissionFor(process.getName()));
|
||||
}
|
||||
return (EmailPermissions.VIEW);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the current session holds the permission (or a higher one).
|
||||
*******************************************************************************/
|
||||
public static boolean has(String permission)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(session.hasPermission(EmailPermissions.ADMIN))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(EmailPermissions.ADMIN.equals(permission))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(session.hasPermission(EmailPermissions.MANAGE))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (session.hasPermission(permission));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email permissions: view, send, manage and admin, plus the derived app access.
|
||||
** Administrators imply all permissions; managers may also send and view.
|
||||
*******************************************************************************/
|
||||
public final class EmailPermissions
|
||||
{
|
||||
public static final String SEND = "email.send";
|
||||
public static final String VIEW = "email.view";
|
||||
public static final String MANAGE = "email.manage";
|
||||
public static final String ADMIN = "email.admin";
|
||||
public static final String HAS_ACCESS = "email.hasAccess";
|
||||
|
||||
public static final List<String> ALL = List.of(VIEW, SEND, MANAGE, ADMIN, HAS_ACCESS);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailPermissions()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* 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.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRenderer;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
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.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Review/Result record actions and hidden scheduled maintenance.
|
||||
*******************************************************************************/
|
||||
public final class EmailProcesses
|
||||
{
|
||||
public static final String RESEND = "email_resend";
|
||||
public static final String CANCEL = "email_cancel";
|
||||
public static final String SEND_NOW = "email_send_now";
|
||||
public static final String SEND_TEST = "email_send_test";
|
||||
public static final String SEND_TEMPLATED = "email_send_templated";
|
||||
public static final String DISPATCH = "email_dispatch";
|
||||
public static final String MAINTAIN = "email_maintain";
|
||||
public static final String RETENTION = "email_retention";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required action permission.
|
||||
*******************************************************************************/
|
||||
public static String permissionFor(String name)
|
||||
{
|
||||
return (switch(name)
|
||||
{
|
||||
case RESEND, CANCEL, SEND_NOW -> EmailPermissions.MANAGE;
|
||||
case SEND_TEMPLATED -> EmailPermissions.SEND;
|
||||
default -> EmailPermissions.ADMIN;
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All actions and schedules; none is an app child.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes(EmailQBitConfig config)
|
||||
{
|
||||
QProcessMetaData test = action(SEND_TEST, "Send Test Email", EmailTables.PROVIDER);
|
||||
test.withStep(0, new QFrontendStepMetaData().withName("input").withLabel("Recipient")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("toAddress", QFieldType.STRING).withLabel("To address").withIsRequired(true)));
|
||||
QFunctionInputMetaData inputs = new QFunctionInputMetaData();
|
||||
for(String field : List.of("templateName", "to", "cc", "bcc", "replyTo", "subject", "markdownBody", "htmlBody", "textBody", "providerName", "brandName",
|
||||
"category", "variablesJson", "linkedTableName", "linkedRecordId", "idempotencyKey"))
|
||||
{
|
||||
inputs.withField(new QFieldMetaData(field, QFieldType.STRING));
|
||||
}
|
||||
QProcessMetaData send = new QProcessMetaData().withName(SEND_TEMPLATED).withLabel("Send Email").withIsHidden(true)
|
||||
.withPermissionRules(EmailTables.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("send").withCode(new QCodeReference(SendStep.class)).withInputData(inputs));
|
||||
return (List.of(action(RESEND, "Resend", EmailTables.MESSAGE), action(CANCEL, "Cancel", EmailTables.MESSAGE),
|
||||
action(SEND_NOW, "Send Now", EmailTables.MESSAGE), test, send,
|
||||
scheduled(config, DISPATCH, "Dispatch Email", config.getDispatchIntervalSeconds()),
|
||||
scheduled(config, MAINTAIN, "Maintain Email", 300), scheduled(config, RETENTION, "Email Retention", 86400)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard streamed-ETL action.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData action(String name, String label, String table)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_FETCH_HEAVY_FIELDS, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected records.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, ActionTransform.class, ActionLoad.class, values)
|
||||
.withName(name).withLabel(label).withIcon(new QIcon("mail")).withTableName(table).withPermissionRules(EmailTables.permissionRules()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden backend schedule; without a scheduler manual dispatch remains possible.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData scheduled(EmailQBitConfig config, String name, String label, int seconds)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(name).withLabel(label).withIsHidden(true)
|
||||
.withPermissionRules(EmailTables.permissionRules()).withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduledStep.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withInitialDelaySeconds(Math.min(seconds, 30)));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a selected row can participate in the action.
|
||||
*******************************************************************************/
|
||||
private static boolean eligible(String process, QRecord row)
|
||||
{
|
||||
if(SEND_TEST.equals(process))
|
||||
{
|
||||
return (Boolean.TRUE.equals(row.getValueBoolean("enabled")));
|
||||
}
|
||||
String status = row.getValueString("status");
|
||||
if(RESEND.equals(process))
|
||||
{
|
||||
return (List.of("DEAD_LETTER", "BOUNCED", "COMPLAINED").contains(status)
|
||||
&& (row.getValueString("htmlBody") != null || row.getValueString("textBody") != null));
|
||||
}
|
||||
return (EmailDispatcher.DUE.contains(status));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review screen validation without side effects.
|
||||
*******************************************************************************/
|
||||
public static class ActionTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Filter rows and explain skipped records.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
boolean valid = eligible(input.getProcessName(), row);
|
||||
lines.add(new ProcessSummaryLine(valid ? Status.OK : Status.INFO, 1, valid ? "record will be processed" : "record skipped: status or retained content does not permit this action"));
|
||||
if(valid)
|
||||
{
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute only after review; re-read rows to avoid stale actions.
|
||||
*******************************************************************************/
|
||||
public static class ActionLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Perform and report one selected row at a time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String process = input.getProcessName();
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
Integer id = row.getValueInteger("id");
|
||||
QRecord current = EmailStore.get(SEND_TEST.equals(process) ? EmailTables.PROVIDER : EmailTables.MESSAGE, id, null);
|
||||
String result = "Skipped";
|
||||
if(current != null && eligible(process, current))
|
||||
{
|
||||
result = switch(process)
|
||||
{
|
||||
case SEND_NOW -> new EmailDispatcher().sendNow(id).name();
|
||||
case CANCEL -> cancel(id) ? "CANCELLED" : "Skipped";
|
||||
case RESEND -> resend(current).status().name();
|
||||
case SEND_TEST -> sendTest(current, input.getValueString("toAddress"));
|
||||
default -> "Skipped";
|
||||
};
|
||||
}
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, 1, "record: " + result));
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result screen lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel under the same database gate as claims.
|
||||
*******************************************************************************/
|
||||
public static boolean cancel(Integer id) throws QException
|
||||
{
|
||||
QRecord row = EmailStore.get(EmailTables.MESSAGE, id, null);
|
||||
if(row == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(EmailTables.PROVIDER)))
|
||||
{
|
||||
EmailStore.lockProvider(row.getValueInteger("providerId"), tx);
|
||||
row = EmailStore.get(EmailTables.MESSAGE, id, tx);
|
||||
if(!EmailDispatcher.DUE.contains(row.getValueString("status")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
EmailStore.update(EmailTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", "CANCELLED").withValue("finishedDate", EmailRuntime.clock().instant()).withValue("nextAttemptDate", null), tx);
|
||||
tx.commit();
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh, linked message through suppression and provider validation again.
|
||||
*******************************************************************************/
|
||||
private static EmailSendResult resend(QRecord row) throws QException
|
||||
{
|
||||
QRecord provider = EmailStore.get(EmailTables.PROVIDER, row.getValueInteger("providerId"), null);
|
||||
EmailRequest request = new EmailRequest().withSubject(row.getValueString("subject")).withHtmlBody(row.getValueString("htmlBody"))
|
||||
.withTextBody(row.getValueString("textBody")).withFrom(row.getValueString("fromAddress")).withReplyTo(row.getValueString("replyTo"))
|
||||
.withProviderName(provider.getValueString("name")).withCategory(EmailCategory.valueOf(row.getValueString("category")))
|
||||
.withLinkedRecord(EmailTables.MESSAGE, row.getValue("id")).withSender("email", RESEND);
|
||||
for(String field : List.of("toAddresses", "ccAddresses", "bccAddresses"))
|
||||
{
|
||||
String value = row.getValueString(field);
|
||||
if(value != null && !value.isBlank())
|
||||
{
|
||||
switch(field)
|
||||
{
|
||||
case "toAddresses" -> request.withTo(value);
|
||||
case "ccAddresses" -> request.withCc(value);
|
||||
default -> request.withBcc(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var field : EmailRuntime.config().getSecurityFields())
|
||||
{
|
||||
request.getSecurityValues().put(field.getName(), row.getValue(field.getName()));
|
||||
}
|
||||
return (EmailService.send(request, ignored -> new EmailRenderer.Rendered(row.getValueString("htmlBody"), row.getValueString("textBody"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue and dispatch a fixed provider test message.
|
||||
*******************************************************************************/
|
||||
private static String sendTest(QRecord provider, String to) throws QException
|
||||
{
|
||||
if(to == null)
|
||||
{
|
||||
return ("REFUSED: a to address is required");
|
||||
}
|
||||
EmailSendResult result = EmailService.send(new EmailRequest().withProviderName(provider.getValueString("name")).withTo(to)
|
||||
.withSubject("Email delivery test").withMarkdownBody("This is a test of your email provider.").withSender("email", SEND_TEST));
|
||||
return (result.status() == EmailSendResult.Status.QUEUED ? new EmailDispatcher().sendNow(result.messageId()).name() : result.status().name());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend sending API with inputs declared as QQQ fields.
|
||||
*******************************************************************************/
|
||||
public static class SendStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Map process inputs to the same service API.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
EmailRequest request = new EmailRequest().withTemplateName(input.getValueString("templateName"))
|
||||
.withSubject(input.getValueString("subject")).withMarkdownBody(input.getValueString("markdownBody"))
|
||||
.withHtmlBody(input.getValueString("htmlBody")).withTextBody(input.getValueString("textBody"))
|
||||
.withReplyTo(input.getValueString("replyTo")).withProviderName(input.getValueString("providerName"))
|
||||
.withBrandName(input.getValueString("brandName")).withIdempotencyKey(input.getValueString("idempotencyKey"))
|
||||
.withLinkedRecord(input.getValueString("linkedTableName"), input.getValueString("linkedRecordId"));
|
||||
if(input.getValueString("to") != null)
|
||||
{
|
||||
request.withTo(input.getValueString("to"));
|
||||
}
|
||||
if(input.getValueString("cc") != null)
|
||||
{
|
||||
request.withCc(input.getValueString("cc"));
|
||||
}
|
||||
if(input.getValueString("bcc") != null)
|
||||
{
|
||||
request.withBcc(input.getValueString("bcc"));
|
||||
}
|
||||
if(input.getValueString("variablesJson") != null)
|
||||
{
|
||||
request.withVariable("variablesJson", input.getValueString("variablesJson"));
|
||||
}
|
||||
if(input.getValueString("category") != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.withCategory(EmailCategory.valueOf(input.getValueString("category")));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Unknown email category"));
|
||||
}
|
||||
}
|
||||
EmailSendResult result = EmailService.send(request);
|
||||
output.addValue("messageId", result.messageId());
|
||||
output.addValue("status", result.status().name());
|
||||
output.addValue("reasons", String.join("; ", result.reasons()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduler entry point.
|
||||
*******************************************************************************/
|
||||
public static class ScheduledStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Dispatch, recover leases and probe health, or retain final messages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case RETENTION -> retain();
|
||||
case MAINTAIN -> new EmailDispatcher().maintain();
|
||||
default -> new EmailDispatcher().runOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear retained bodies and optionally delete final messages and their attempts.
|
||||
*******************************************************************************/
|
||||
public static void retain() throws QException
|
||||
{
|
||||
EmailQBitConfig config = EmailRuntime.config();
|
||||
Instant now = EmailRuntime.clock().instant();
|
||||
for(QRecord row : SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, new ArrayList<>(EmailDispatcher.FINAL)))))
|
||||
{
|
||||
Instant finalDate = row.getValueInstant("finishedDate");
|
||||
if(finalDate == null)
|
||||
{
|
||||
finalDate = row.getValueInstant("sentDate");
|
||||
}
|
||||
if(finalDate == null)
|
||||
{
|
||||
finalDate = row.getValueInstant("deadLetterDate");
|
||||
}
|
||||
if(finalDate == null)
|
||||
{
|
||||
finalDate = row.getValueInstant("createDate");
|
||||
}
|
||||
if(finalDate == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(config.getMessageRetentionDays() > 0 && finalDate.isBefore(now.minus(Duration.ofDays(config.getMessageRetentionDays()))))
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
SystemWrites.delete(EmailTables.ATTEMPT, EmailStore.equal("messageId", row.getValueInteger("id")), transaction);
|
||||
SystemWrites.delete(EmailTables.MESSAGE, EmailStore.equal("id", row.getValueInteger("id")), transaction);
|
||||
transaction.commit();
|
||||
}
|
||||
}
|
||||
else if(finalDate.isBefore(now.minus(Duration.ofDays(config.getBodyRetentionDays()))) && (row.getValue("htmlBody") != null || row.getValue("textBody") != null))
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", row.getValue("id")).withValue("htmlBody", null).withValue("textBody", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small database helpers shared by dispatcher and actions.
|
||||
*******************************************************************************/
|
||||
public final class EmailStore
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailStore()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Equality filter.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter equal(String field, Serializable value)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a row in a transaction.
|
||||
*******************************************************************************/
|
||||
public static QRecord get(String table, Integer id, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
List<QRecord> rows = SystemWrites.query(table, equal("id", id), transaction);
|
||||
return (rows.isEmpty() ? null : rows.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check write errors without exposing record contents.
|
||||
*******************************************************************************/
|
||||
public static void update(String table, QRecord row, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
List<QRecord> rows = SystemWrites.update(table, List.of(row), transaction);
|
||||
if(rows.isEmpty() || (rows.get(0).getErrors() != null && !rows.get(0).getErrors().isEmpty()))
|
||||
{
|
||||
throw (new QException("Email database update failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize provider rate checks and claims using the database row lock.
|
||||
*******************************************************************************/
|
||||
public static QRecord lockProvider(Integer id, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(get(EmailTables.PROVIDER, id, transaction) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
update(EmailTables.PROVIDER, new QRecord().withValue("id", id).withValue("dispatchLock", 0), transaction);
|
||||
return (get(EmailTables.PROVIDER, id, transaction));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
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.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's own reads and writes (the delivery engine, the outbox, health
|
||||
** and rotation). They run in core's authorized scope, so they work with core
|
||||
** permission enforcement on, and they mark the thread so the tables'
|
||||
** customizers can tell a qbit write from a person's or the API's.
|
||||
*******************************************************************************/
|
||||
public final class SystemWrites
|
||||
{
|
||||
private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work that may throw.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Work<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Do it.
|
||||
*******************************************************************************/
|
||||
T run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SystemWrites()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run work as the qbit.
|
||||
*******************************************************************************/
|
||||
public static <T> T call(Work<T> work) throws QException
|
||||
{
|
||||
DEPTH.set(DEPTH.get() + 1);
|
||||
try(CorePermissionEnforcer.AuthorizedScope ignored = CorePermissionEnforcer.openAuthorizedScope())
|
||||
{
|
||||
return (work.run());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DEPTH.set(DEPTH.get() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True while the qbit itself is writing on this thread.
|
||||
*******************************************************************************/
|
||||
public static boolean active()
|
||||
{
|
||||
return (DEPTH.get() > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert, returning the output (records may carry errors).
|
||||
*******************************************************************************/
|
||||
public static InsertOutput insert(String table, List<QRecord> records, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
return (call(() -> new InsertAction().execute(new InsertInput(table).withRecords(records).withTransaction(transaction))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one record, returning it (with errors if it failed).
|
||||
*******************************************************************************/
|
||||
public static QRecord insertOne(String table, QRecord record) throws QException
|
||||
{
|
||||
return (insert(table, List.of(record), null).getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> update(String table, List<QRecord> records, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(records.isEmpty())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
return (call(() -> new UpdateAction().execute(new UpdateInput(table).withRecords(records).withTransaction(transaction)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update one record.
|
||||
*******************************************************************************/
|
||||
public static void updateOne(String table, QRecord record) throws QException
|
||||
{
|
||||
update(table, List.of(record), null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query in a transaction.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter).withTransaction(transaction)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count.
|
||||
*******************************************************************************/
|
||||
public static int count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (count(table, filter, null));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count within the claim transaction so rate budgets remain shared.
|
||||
*******************************************************************************/
|
||||
public static int count(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Integer count = call(() -> new CountAction().execute(new CountInput(table).withFilter(filter).withTransaction(transaction)).getCount());
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete by filter, returning the number deleted.
|
||||
*******************************************************************************/
|
||||
public static int delete(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (delete(table, filter, null));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete within a transaction, including retention's parent and child rows.
|
||||
*******************************************************************************/
|
||||
public static int delete(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Integer deleted = call(() -> new DeleteAction().execute(new DeleteInput(table).withQueryFilter(filter).withTransaction(transaction)).getDeletedRecordCount());
|
||||
return (deleted == null ? 0 : deleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rendered, immutable-by-convention content passed to a transport.
|
||||
*******************************************************************************/
|
||||
public class EmailEnvelope
|
||||
{
|
||||
private String messageId;
|
||||
private String from;
|
||||
private List<String> to = List.of();
|
||||
private List<String> cc = List.of();
|
||||
private List<String> bcc = List.of();
|
||||
private String replyTo;
|
||||
private String subject;
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
private Map<String, String> headers = Map.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for messageId.
|
||||
*******************************************************************************/
|
||||
public String getMessageId()
|
||||
{
|
||||
return (messageId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for messageId.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withMessageId(String messageId)
|
||||
{
|
||||
this.messageId = messageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for from.
|
||||
*******************************************************************************/
|
||||
public String getFrom()
|
||||
{
|
||||
return (from);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for from.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withFrom(String from)
|
||||
{
|
||||
this.from = from;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for to.
|
||||
*******************************************************************************/
|
||||
public List<String> getTo()
|
||||
{
|
||||
return (to);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for to.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withTo(List<String> to)
|
||||
{
|
||||
this.to = to;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cc.
|
||||
*******************************************************************************/
|
||||
public List<String> getCc()
|
||||
{
|
||||
return (cc);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cc.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withCc(List<String> cc)
|
||||
{
|
||||
this.cc = cc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bcc.
|
||||
*******************************************************************************/
|
||||
public List<String> getBcc()
|
||||
{
|
||||
return (bcc);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bcc.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withBcc(List<String> bcc)
|
||||
{
|
||||
this.bcc = bcc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for replyTo.
|
||||
*******************************************************************************/
|
||||
public String getReplyTo()
|
||||
{
|
||||
return (replyTo);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for replyTo.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withReplyTo(String replyTo)
|
||||
{
|
||||
this.replyTo = replyTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subject.
|
||||
*******************************************************************************/
|
||||
public String getSubject()
|
||||
{
|
||||
return (subject);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subject.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withSubject(String subject)
|
||||
{
|
||||
this.subject = subject;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public String getHtmlBody()
|
||||
{
|
||||
return (htmlBody);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for htmlBody.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withHtmlBody(String htmlBody)
|
||||
{
|
||||
this.htmlBody = htmlBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for textBody.
|
||||
*******************************************************************************/
|
||||
public String getTextBody()
|
||||
{
|
||||
return (textBody);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for textBody.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withTextBody(String textBody)
|
||||
{
|
||||
this.textBody = textBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for headers.
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getHeaders()
|
||||
{
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for headers.
|
||||
*******************************************************************************/
|
||||
public EmailEnvelope withHeaders(Map<String, String> headers)
|
||||
{
|
||||
this.headers = headers;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible HTTP seam; no provider SDK, credential cache or filesystem.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface EmailHttp
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Exchange one request without retries.
|
||||
*******************************************************************************/
|
||||
HttpResponse<String> send(HttpRequest request) throws IOException, InterruptedException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production requests use HTTPS and never forward credentials on redirects.
|
||||
*******************************************************************************/
|
||||
static HttpResponse<String> sendJdk(HttpRequest request) throws IOException, InterruptedException
|
||||
{
|
||||
if(!"https".equalsIgnoreCase(request.uri().getScheme()))
|
||||
{
|
||||
throw (new IOException("Email HTTP requires HTTPS"));
|
||||
}
|
||||
try(HttpClient client = newClient())
|
||||
{
|
||||
return (client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded, stateless JDK client; the caller closes it after each exchange.
|
||||
*******************************************************************************/
|
||||
static HttpClient newClient()
|
||||
{
|
||||
return (HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).followRedirects(HttpClient.Redirect.NEVER).build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared serialization, timeouts and sanitized HTTP failures.
|
||||
*******************************************************************************/
|
||||
final class EmailHttpSupport
|
||||
{
|
||||
static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailHttpSupport()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the shared envelope validation even for JSON APIs.
|
||||
*******************************************************************************/
|
||||
static void validate(EmailEnvelope envelope) throws EmailTransportException
|
||||
{
|
||||
try
|
||||
{
|
||||
EmailMime.build(Session.getInstance(new Properties()), envelope);
|
||||
}
|
||||
catch(MessagingException | RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve application headers and the stable correlation identifier.
|
||||
*******************************************************************************/
|
||||
static Map<String, String> headers(EmailEnvelope envelope)
|
||||
{
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
envelope.getHeaders().forEach((name, value) ->
|
||||
{
|
||||
if(!name.equalsIgnoreCase("Bcc") && !name.equalsIgnoreCase("Auto-Submitted") && !name.equalsIgnoreCase("X-QQQ-Email-Id"))
|
||||
{
|
||||
headers.put(name, value);
|
||||
}
|
||||
});
|
||||
headers.put("Auto-Submitted", "auto-generated");
|
||||
headers.put("X-QQQ-Email-Id", envelope.getMessageId());
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize only in memory using Jackson already supplied by core.
|
||||
*******************************************************************************/
|
||||
static byte[] json(Object value) throws EmailTransportException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JSON.writeValueAsBytes(value));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve auth before calling; never attach raw exceptions to a safe failure.
|
||||
*******************************************************************************/
|
||||
static HttpResponse<String> post(EmailHttp http, String url, String contentType, String authHeader, String auth, byte[] body) throws EmailTransportException
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(30))
|
||||
.header("Content-Type", contentType).header("Accept", "application/json").header(authHeader, auth)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
|
||||
return (http.send(request));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new EmailTransportException("Email HTTP request interrupted", false));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new EmailTransportException("Email HTTP temporarily unavailable", false));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Email HTTP configuration invalid", false).withProviderLevel(true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unparseable replies must not expose bodies or cause permanent message loss.
|
||||
*******************************************************************************/
|
||||
static JsonNode responseJson(HttpResponse<String> response) throws EmailTransportException
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonNode json = JSON.readTree(response.body());
|
||||
if(json != null && json.isObject())
|
||||
{
|
||||
return (json);
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
// Bodies and parser diagnostics may contain recipient addresses.
|
||||
}
|
||||
throw (new EmailTransportException("Email provider returned an invalid response", false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse either Retry-After syntax; the dispatcher applies the one-hour cap.
|
||||
*******************************************************************************/
|
||||
static Duration retryAfter(HttpResponse<String> response)
|
||||
{
|
||||
String value = response.headers().firstValue("Retry-After").orElse("").trim();
|
||||
try
|
||||
{
|
||||
long seconds = Long.parseLong(value);
|
||||
return (seconds < 0 ? null : Duration.ofSeconds(seconds));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Duration delay = Duration.between(Instant.now(), ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
|
||||
return (delay.isNegative() ? Duration.ZERO : delay);
|
||||
}
|
||||
catch(RuntimeException ignored)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only numeric status/code and fixed text escape the transport.
|
||||
*******************************************************************************/
|
||||
static EmailTransportException failure(HttpResponse<String> response, String provider, Integer code, boolean permanent, boolean providerLevel)
|
||||
{
|
||||
return (new EmailTransportException(provider + " request failed (HTTP " + response.statusCode()
|
||||
+ (code == null ? "" : ", code " + code) + ")", permanent).withProviderLevel(providerLevel).withRetryAfter(retryAfter(response)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common HTTP precedence: throttling and server errors always remain retryable.
|
||||
*******************************************************************************/
|
||||
static void checkStatus(HttpResponse<String> response, String provider, int success) throws EmailTransportException
|
||||
{
|
||||
int status = response.statusCode();
|
||||
if(status != success)
|
||||
{
|
||||
boolean transientFailure = status == 408 || status == 429 || status >= 500;
|
||||
boolean providerLevel = status == 401 || status == 403 || (status >= 300 && status < 400);
|
||||
throw (failure(response, provider, null, !transientFailure && !providerLevel && status >= 400, providerLevel));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A success must contain a usable id; never mistake a malformed body for success.
|
||||
*******************************************************************************/
|
||||
static EmailTransportResult result(String id) throws EmailTransportException
|
||||
{
|
||||
if(id == null || id.isBlank() || id.length() > 500 || id.contains("\r") || id.contains("\n"))
|
||||
{
|
||||
throw (new EmailTransportException("Email provider returned an invalid message id", false));
|
||||
}
|
||||
return (new EmailTransportResult(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared UTF-8 multipart MIME construction for SMTP and SES.
|
||||
*******************************************************************************/
|
||||
final class EmailMime
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EmailMime()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct both alternative parts and stable application headers.
|
||||
*******************************************************************************/
|
||||
static MimeMessage build(Session session, EmailEnvelope envelope) throws MessagingException
|
||||
{
|
||||
noLineBreaks(envelope.getSubject());
|
||||
noLineBreaks(envelope.getFrom());
|
||||
noLineBreaks(envelope.getReplyTo());
|
||||
noLineBreaks(envelope.getMessageId());
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
message.setFrom(new InternetAddress(envelope.getFrom(), true));
|
||||
recipients(message, Message.RecipientType.TO, envelope.getTo());
|
||||
recipients(message, Message.RecipientType.CC, envelope.getCc());
|
||||
recipients(message, Message.RecipientType.BCC, envelope.getBcc());
|
||||
if(envelope.getReplyTo() != null)
|
||||
{
|
||||
message.setReplyTo(InternetAddress.parse(envelope.getReplyTo(), true));
|
||||
}
|
||||
message.setSubject(envelope.getSubject(), "UTF-8");
|
||||
message.setSentDate(new Date());
|
||||
MimeMultipart alternative = new MimeMultipart("alternative");
|
||||
MimeBodyPart text = new MimeBodyPart();
|
||||
text.setText(envelope.getTextBody() == null ? "" : envelope.getTextBody(), "UTF-8");
|
||||
alternative.addBodyPart(text);
|
||||
MimeBodyPart html = new MimeBodyPart();
|
||||
html.setContent(envelope.getHtmlBody() == null ? "" : envelope.getHtmlBody(), "text/html; charset=UTF-8");
|
||||
alternative.addBodyPart(html);
|
||||
message.setContent(alternative);
|
||||
for(var header : envelope.getHeaders().entrySet())
|
||||
{
|
||||
if(!header.getKey().matches("[A-Za-z0-9-]+") || header.getValue().contains("\r") || header.getValue().contains("\n"))
|
||||
{
|
||||
throw (new MessagingException("Invalid envelope header"));
|
||||
}
|
||||
message.setHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
message.setHeader("Auto-Submitted", "auto-generated");
|
||||
message.setHeader("X-QQQ-Email-Id", envelope.getMessageId());
|
||||
message.saveChanges();
|
||||
return (message);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one recipient role.
|
||||
*******************************************************************************/
|
||||
private static void recipients(MimeMessage message, Message.RecipientType type, List<String> addresses) throws MessagingException
|
||||
{
|
||||
for(String address : addresses)
|
||||
{
|
||||
noLineBreaks(address);
|
||||
}
|
||||
if(!addresses.isEmpty())
|
||||
{
|
||||
message.setRecipients(type, InternetAddress.parse(String.join(",", addresses), true));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject header injection even for envelopes that bypass queue validation.
|
||||
*******************************************************************************/
|
||||
private static void noLineBreaks(String value) throws MessagingException
|
||||
{
|
||||
if(value != null && (value.contains("\r") || value.contains("\n")))
|
||||
{
|
||||
throw (new MessagingException("Invalid envelope header"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider row and resolver. Resolved secrets are never retained.
|
||||
*******************************************************************************/
|
||||
public final class EmailProviderSettings
|
||||
{
|
||||
private final QRecord row;
|
||||
private final EmailQBitConfig config;
|
||||
|
||||
/*******************************************************************************
|
||||
** Settings for one attempt, without resolving secrets.
|
||||
*******************************************************************************/
|
||||
public EmailProviderSettings(QRecord row, EmailQBitConfig config)
|
||||
{
|
||||
this.row = row;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Nonsecret provider setting.
|
||||
*******************************************************************************/
|
||||
public String value(String name)
|
||||
{
|
||||
return (row.getValueString(name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SMTP port, including the optional Gmail preset.
|
||||
*******************************************************************************/
|
||||
public int port()
|
||||
{
|
||||
return ("GMAIL_SMTP".equals(value("kind")) ? 587 : row.getValueInteger("port"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SMTP host.
|
||||
*******************************************************************************/
|
||||
public String host()
|
||||
{
|
||||
return ("GMAIL_SMTP".equals(value("kind")) ? "smtp.gmail.com" : value("host"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SMTP security.
|
||||
*******************************************************************************/
|
||||
public String security()
|
||||
{
|
||||
return ("GMAIL_SMTP".equals(value("kind")) ? "STARTTLS" : value("security"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared validation and send-time guard, without resolving credentials or DNS.
|
||||
*******************************************************************************/
|
||||
public boolean allowsSmtpAuth()
|
||||
{
|
||||
boolean credentials = value("usernameEnvName") != null || value("secretEnvName") != null;
|
||||
String security = value("security") == null ? security() : value("security");
|
||||
return (!credentials || "STARTTLS".equals(security) || "TLS".equals(security)
|
||||
|| config.getAllowPlaintextSmtpAuth() || "localhost".equalsIgnoreCase(host())
|
||||
|| "127.0.0.1".equals(host()) || "::1".equals(host()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A domain is a single DNS name, never a URL or a path component escape.
|
||||
*******************************************************************************/
|
||||
public static boolean validSendingDomain(String domain)
|
||||
{
|
||||
return (domain != null && domain.length() <= 253
|
||||
&& domain.matches("(?i)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Tracking is opt-in; null database values mean disabled.
|
||||
*******************************************************************************/
|
||||
boolean enabled(String field)
|
||||
{
|
||||
return (Boolean.TRUE.equals(row.getValueBoolean(field)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP providers must never send a missing token as the literal string null.
|
||||
*******************************************************************************/
|
||||
String requiredSecret() throws EmailTransportException
|
||||
{
|
||||
String secret = resolve("secretEnvName");
|
||||
if(secret == null)
|
||||
{
|
||||
throw (new EmailTransportException("Provider needs a secret environment name", false).withProviderLevel(true));
|
||||
}
|
||||
return (secret);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an explicitly named secret immediately before sending.
|
||||
*******************************************************************************/
|
||||
String resolve(String field) throws EmailTransportException
|
||||
{
|
||||
String name = value(field);
|
||||
if(name == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!EmailQBitConfig.ENV_NAME.matcher(name).matches() || !config.effectiveAllowedEnvNames(value("kind")).contains(name))
|
||||
{
|
||||
throw (new EmailTransportException("Credential environment name is not allowed", false).withProviderLevel(true));
|
||||
}
|
||||
try
|
||||
{
|
||||
String value = config.getSecretResolver().apply(name);
|
||||
if(value != null && !value.isBlank())
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// Resolver exception text may contain a secret.
|
||||
}
|
||||
throw (new EmailTransportException("Credential environment variable is not set", false).withProviderLevel(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.email.transport.EmailTransportException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery transport; called only for a claimed outbox attempt.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface EmailTransport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Send a previously rendered envelope.
|
||||
*******************************************************************************/
|
||||
EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sanitized transport failure. Never retain a provider exception or credential.
|
||||
*******************************************************************************/
|
||||
public class EmailTransportException extends Exception
|
||||
{
|
||||
private boolean permanent;
|
||||
private boolean providerLevel;
|
||||
private Duration retryAfter;
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a safe failure with a permanent or transient classification.
|
||||
*******************************************************************************/
|
||||
public EmailTransportException(String message, boolean permanent)
|
||||
{
|
||||
super(message);
|
||||
this.permanent = permanent;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for permanent.
|
||||
*******************************************************************************/
|
||||
public boolean getPermanent()
|
||||
{
|
||||
return (permanent && !providerLevel);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider failures are retryable without consuming the message retry budget.
|
||||
*******************************************************************************/
|
||||
public boolean getProviderLevel()
|
||||
{
|
||||
return (providerLevel);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify a failure affecting the provider rather than this message.
|
||||
*******************************************************************************/
|
||||
public EmailTransportException withProviderLevel(boolean providerLevel)
|
||||
{
|
||||
this.providerLevel = providerLevel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for permanent.
|
||||
*******************************************************************************/
|
||||
public EmailTransportException withPermanent(boolean permanent)
|
||||
{
|
||||
this.permanent = permanent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for retryAfter.
|
||||
*******************************************************************************/
|
||||
public Duration getRetryAfter()
|
||||
{
|
||||
return (retryAfter);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for retryAfter.
|
||||
*******************************************************************************/
|
||||
public EmailTransportException withRetryAfter(Duration retryAfter)
|
||||
{
|
||||
this.retryAfter = retryAfter;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-dispatcher registry; no configuration or credential caches.
|
||||
*******************************************************************************/
|
||||
public class EmailTransportRegistry
|
||||
{
|
||||
private final Map<String, EmailTransport> transports = new HashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the outbound transports.
|
||||
*******************************************************************************/
|
||||
public EmailTransportRegistry()
|
||||
{
|
||||
transports.put("SMTP", new SmtpEmailTransport());
|
||||
transports.put("GMAIL_SMTP", new SmtpEmailTransport());
|
||||
transports.put("SES", new SesEmailTransport());
|
||||
transports.put("POSTMARK", new PostmarkEmailTransport());
|
||||
transports.put("SENDGRID", new SendGridEmailTransport());
|
||||
transports.put("MAILGUN", new MailgunEmailTransport());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host or test transport override.
|
||||
*******************************************************************************/
|
||||
public EmailTransportRegistry withTransport(String kind, EmailTransport transport)
|
||||
{
|
||||
transports.put(kind, transport);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a kind, explicitly refusing future transports.
|
||||
*******************************************************************************/
|
||||
public EmailTransport get(String kind) throws EmailTransportException
|
||||
{
|
||||
EmailTransport transport = transports.get(kind);
|
||||
if(transport == null)
|
||||
{
|
||||
throw (new EmailTransportException("GMAIL_API".equals(kind)
|
||||
? "Gmail API sending is specified but not built; use GMAIL_SMTP" : "Unknown email provider kind", true));
|
||||
}
|
||||
return (transport);
|
||||
}
|
||||
}
|
||||
@@ -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.transport;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider acknowledgment, never a delivery receipt.
|
||||
*******************************************************************************/
|
||||
public record EmailTransportResult(String providerMessageId)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mailgun MIME upload, entirely in memory, with explicit blind destinations.
|
||||
*******************************************************************************/
|
||||
public class MailgunEmailTransport implements EmailTransport
|
||||
{
|
||||
private final EmailHttp http;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed regional production endpoints and JDK HTTP implementation.
|
||||
*******************************************************************************/
|
||||
public MailgunEmailTransport()
|
||||
{
|
||||
this(EmailHttp::sendJdk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible request/response test seam.
|
||||
*******************************************************************************/
|
||||
MailgunEmailTransport(EmailHttp http)
|
||||
{
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Envelope destinations include Bcc; serialized MIME never includes its header.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
|
||||
{
|
||||
String domain = settings.value("sendingDomain");
|
||||
String region = settings.value("region");
|
||||
if(!EmailProviderSettings.validSendingDomain(domain) || (!"US".equals(region) && !"EU".equals(region)))
|
||||
{
|
||||
throw (new EmailTransportException("Mailgun needs a sending domain and US or EU region", false).withProviderLevel(true));
|
||||
}
|
||||
String boundary = "qqq-email-" + UUID.randomUUID();
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
try
|
||||
{
|
||||
List<String> destinations = new ArrayList<>();
|
||||
for(List<String> role : List.of(envelope.getTo(), envelope.getCc(), envelope.getBcc()))
|
||||
{
|
||||
for(String value : role)
|
||||
{
|
||||
for(InternetAddress address : InternetAddress.parse(value, true))
|
||||
{
|
||||
address.validate();
|
||||
destinations.add(address.getAddress());
|
||||
}
|
||||
}
|
||||
}
|
||||
field(body, boundary, "to", String.join(",", destinations));
|
||||
field(body, boundary, "o:tracking-opens", settings.enabled("trackOpens") ? "yes" : "no");
|
||||
field(body, boundary, "o:tracking-clicks", settings.enabled("trackClicks") ? "yes" : "no");
|
||||
field(body, boundary, "v:qqq_email_id", envelope.getMessageId());
|
||||
body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"message\"; filename=\"message.eml\"\r\n"
|
||||
+ "Content-Type: message/rfc822\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
EmailMime.build(Session.getInstance(new Properties()), envelope).writeTo(body, new String[] {"Bcc"});
|
||||
body.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(MessagingException | IOException | RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
String auth = Base64.getEncoder().encodeToString(("api:" + settings.requiredSecret()).getBytes(StandardCharsets.UTF_8));
|
||||
String host = "EU".equals(region) ? "https://api.eu.mailgun.net" : "https://api.mailgun.net";
|
||||
HttpResponse<String> response = EmailHttpSupport.post(http, host + "/v3/" + domain + "/messages.mime",
|
||||
"multipart/form-data; boundary=" + boundary, "Authorization", "Basic " + auth, body.toByteArray());
|
||||
EmailHttpSupport.checkStatus(response, "Mailgun", 200);
|
||||
String id = EmailHttpSupport.responseJson(response).path("id").textValue();
|
||||
if(id != null && id.startsWith("<") && id.endsWith(">"))
|
||||
{
|
||||
id = id.substring(1, id.length() - 1);
|
||||
}
|
||||
return (EmailHttpSupport.result(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode fixed-name UTF-8 multipart fields without touching the filesystem.
|
||||
*******************************************************************************/
|
||||
private static void field(ByteArrayOutputStream body, String boundary, String name, String value)
|
||||
{
|
||||
body.writeBytes(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
|
||||
+ value + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Postmark's single-message API with per-attempt server token resolution.
|
||||
*******************************************************************************/
|
||||
public class PostmarkEmailTransport implements EmailTransport
|
||||
{
|
||||
private final EmailHttp http;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed production endpoint and JDK HTTP implementation.
|
||||
*******************************************************************************/
|
||||
public PostmarkEmailTransport()
|
||||
{
|
||||
this(EmailHttp::sendJdk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible request/response test seam.
|
||||
*******************************************************************************/
|
||||
PostmarkEmailTransport(EmailHttp http)
|
||||
{
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send a rendered message; provider response diagnostics never escape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
|
||||
{
|
||||
EmailHttpSupport.validate(envelope);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("From", envelope.getFrom());
|
||||
body.put("To", String.join(",", envelope.getTo()));
|
||||
body.put("Cc", String.join(",", envelope.getCc()));
|
||||
body.put("Bcc", String.join(",", envelope.getBcc()));
|
||||
if(envelope.getReplyTo() != null)
|
||||
{
|
||||
body.put("ReplyTo", envelope.getReplyTo());
|
||||
}
|
||||
body.put("Subject", envelope.getSubject());
|
||||
if(envelope.getHtmlBody() != null)
|
||||
{
|
||||
body.put("HtmlBody", envelope.getHtmlBody());
|
||||
}
|
||||
if(envelope.getTextBody() != null)
|
||||
{
|
||||
body.put("TextBody", envelope.getTextBody());
|
||||
}
|
||||
body.put("Headers", EmailHttpSupport.headers(envelope).entrySet().stream()
|
||||
.map(h -> Map.of("Name", h.getKey(), "Value", h.getValue())).toList());
|
||||
String stream = settings.value("messageStream");
|
||||
body.put("MessageStream", stream == null || stream.isBlank() ? "outbound" : stream);
|
||||
body.put("TrackOpens", settings.enabled("trackOpens"));
|
||||
body.put("TrackLinks", settings.enabled("trackClicks") ? "HtmlAndText" : "None");
|
||||
HttpResponse<String> response = EmailHttpSupport.post(http, "https://api.postmarkapp.com/email", "application/json",
|
||||
"X-Postmark-Server-Token", settings.requiredSecret(), EmailHttpSupport.json(body));
|
||||
int status = response.statusCode();
|
||||
if(status == 408 || status == 429 || status >= 500 || status == 401 || status == 403 || (status >= 300 && status < 400))
|
||||
{
|
||||
EmailHttpSupport.checkStatus(response, "Postmark", 200);
|
||||
}
|
||||
JsonNode json;
|
||||
try
|
||||
{
|
||||
json = EmailHttpSupport.responseJson(response);
|
||||
}
|
||||
catch(EmailTransportException e)
|
||||
{
|
||||
EmailHttpSupport.checkStatus(response, "Postmark", 200);
|
||||
throw (e);
|
||||
}
|
||||
Integer code = json.path("ErrorCode").canConvertToInt() ? json.path("ErrorCode").intValue() : null;
|
||||
if(status == 200 && code == null)
|
||||
{
|
||||
throw (new EmailTransportException("Email provider returned an invalid response", false));
|
||||
}
|
||||
if(status != 200 || code == null || code != 0)
|
||||
{
|
||||
// Postmark ErrorCode (not HTTP status) | Classification
|
||||
// 10 bad token | provider-level
|
||||
// 300 invalid request, 406 inactive | permanent message failure
|
||||
// 400/401 sender signature, 405 send disabled, 412 pending approval,
|
||||
// 413 account unapproved, 422 invalid server, 1235/1236 stream,
|
||||
// 1480 IP restriction | provider-level
|
||||
// HTTP 429 / 5xx | transient, takes precedence
|
||||
// Other request codes | permanent (unknown 200 is transient)
|
||||
boolean providerLevel = code != null && Set.of(10, 400, 401, 405, 412, 413, 422, 1235, 1236, 1480).contains(code);
|
||||
throw (EmailHttpSupport.failure(response, "Postmark", code, !providerLevel && (status >= 400 || code != null && code != 0), providerLevel));
|
||||
}
|
||||
return (EmailHttpSupport.result(json.path("MessageID").textValue()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SendGrid v3 mail send with explicit per-message tracking settings.
|
||||
*******************************************************************************/
|
||||
public class SendGridEmailTransport implements EmailTransport
|
||||
{
|
||||
private static final Set<String> RESERVED_HEADERS = Set.of("x-sg-id", "x-sg-eid", "received", "dkim-signature",
|
||||
"content-type", "content-transfer-encoding", "to", "from", "subject", "reply-to", "cc", "bcc");
|
||||
private final EmailHttp http;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed production endpoint and JDK HTTP implementation.
|
||||
*******************************************************************************/
|
||||
public SendGridEmailTransport()
|
||||
{
|
||||
this(EmailHttp::sendJdk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible request/response test seam.
|
||||
*******************************************************************************/
|
||||
SendGridEmailTransport(EmailHttp http)
|
||||
{
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build one personalization, keeping blind recipients out of custom headers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
|
||||
{
|
||||
EmailHttpSupport.validate(envelope);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
Map<String, Object> personalization = new LinkedHashMap<>();
|
||||
personalization.put("to", addresses(envelope.getTo()));
|
||||
if(!envelope.getCc().isEmpty())
|
||||
{
|
||||
personalization.put("cc", addresses(envelope.getCc()));
|
||||
}
|
||||
if(!envelope.getBcc().isEmpty())
|
||||
{
|
||||
personalization.put("bcc", addresses(envelope.getBcc()));
|
||||
}
|
||||
body.put("personalizations", List.of(personalization));
|
||||
body.put("from", addresses(List.of(envelope.getFrom())).getFirst());
|
||||
if(envelope.getReplyTo() != null)
|
||||
{
|
||||
body.put("reply_to", addresses(List.of(envelope.getReplyTo())).getFirst());
|
||||
}
|
||||
body.put("subject", envelope.getSubject());
|
||||
List<Map<String, String>> content = new ArrayList<>();
|
||||
if(envelope.getTextBody() != null && !envelope.getTextBody().isEmpty())
|
||||
{
|
||||
content.add(Map.of("type", "text/plain", "value", envelope.getTextBody()));
|
||||
}
|
||||
if(envelope.getHtmlBody() != null && !envelope.getHtmlBody().isEmpty())
|
||||
{
|
||||
content.add(Map.of("type", "text/html", "value", envelope.getHtmlBody()));
|
||||
}
|
||||
body.put("content", content);
|
||||
Map<String, String> headers = EmailHttpSupport.headers(envelope);
|
||||
headers.keySet().removeIf(name -> RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)));
|
||||
body.put("headers", headers);
|
||||
body.put("custom_args", Map.of("qqq_email_id", envelope.getMessageId()));
|
||||
body.put("tracking_settings", Map.of("open_tracking", Map.of("enable", settings.enabled("trackOpens")),
|
||||
"click_tracking", Map.of("enable", settings.enabled("trackClicks"), "enable_text", settings.enabled("trackClicks"))));
|
||||
HttpResponse<String> response = EmailHttpSupport.post(http, "https://api.sendgrid.com/v3/mail/send", "application/json",
|
||||
"Authorization", "Bearer " + settings.requiredSecret(), EmailHttpSupport.json(body));
|
||||
EmailHttpSupport.checkStatus(response, "SendGrid", 202);
|
||||
return (EmailHttpSupport.result(response.headers().firstValue("X-Message-Id").orElse(null)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert mailboxes without losing their decoded display names.
|
||||
*******************************************************************************/
|
||||
private static List<Map<String, String>> addresses(List<String> values) throws EmailTransportException
|
||||
{
|
||||
List<Map<String, String>> result = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
for(String value : values)
|
||||
{
|
||||
for(InternetAddress address : InternetAddress.parse(value, true))
|
||||
{
|
||||
address.validate();
|
||||
Map<String, String> entry = new LinkedHashMap<>();
|
||||
entry.put("email", address.getAddress());
|
||||
if(address.getPersonal() != null)
|
||||
{
|
||||
entry.put("name", address.getPersonal());
|
||||
}
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(AddressException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.sesv2.SesV2Client;
|
||||
import software.amazon.awssdk.services.sesv2.SesV2ClientBuilder;
|
||||
import software.amazon.awssdk.services.sesv2.model.Destination;
|
||||
import software.amazon.awssdk.services.sesv2.model.EmailContent;
|
||||
import software.amazon.awssdk.services.sesv2.model.MessageRejectedException;
|
||||
import software.amazon.awssdk.services.sesv2.model.RawMessage;
|
||||
import software.amazon.awssdk.services.sesv2.model.SendEmailRequest;
|
||||
import software.amazon.awssdk.services.sesv2.model.SesV2Exception;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SES v2 raw MIME transport; credentials are resolved only for an attempt.
|
||||
*******************************************************************************/
|
||||
public class SesEmailTransport implements EmailTransport
|
||||
{
|
||||
private final Function<SesV2ClientBuilder, SesV2Client> clientFactory;
|
||||
|
||||
/*******************************************************************************
|
||||
** Production client factory.
|
||||
*******************************************************************************/
|
||||
public SesEmailTransport()
|
||||
{
|
||||
this(SesV2ClientBuilder::build);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-visible fake-client seam.
|
||||
*******************************************************************************/
|
||||
SesEmailTransport(Function<SesV2ClientBuilder, SesV2Client> clientFactory)
|
||||
{
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the same MIME parts and headers as SMTP.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
|
||||
{
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try
|
||||
{
|
||||
EmailMime.build(Session.getInstance(new Properties()), envelope).writeTo(bytes, new String[] {"Bcc"});
|
||||
}
|
||||
catch(MessagingException | IOException | RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
AtomicBoolean credentialsFailed = new AtomicBoolean();
|
||||
try(DefaultCredentialsProvider defaultCredentials = DefaultCredentialsProvider.builder().build())
|
||||
{
|
||||
SesV2ClientBuilder builder = SesV2Client.builder().region(Region.of(settings.value("region")))
|
||||
.overrideConfiguration(c -> c.apiCallTimeout(Duration.ofSeconds(40)).apiCallAttemptTimeout(Duration.ofSeconds(35))
|
||||
.retryPolicy(software.amazon.awssdk.core.retry.RetryPolicy.none()));
|
||||
if(settings.value("usernameEnvName") != null)
|
||||
{
|
||||
builder.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(settings.resolve("usernameEnvName"), settings.resolve("secretEnvName"))));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.credentialsProvider(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return (defaultCredentials.resolveCredentials());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
credentialsFailed.set(true);
|
||||
throw (e);
|
||||
}
|
||||
});
|
||||
}
|
||||
SendEmailRequest request = SendEmailRequest.builder()
|
||||
.fromEmailAddress(envelope.getFrom())
|
||||
.destination(Destination.builder().toAddresses(destinations(envelope.getTo())).ccAddresses(destinations(envelope.getCc())).bccAddresses(destinations(envelope.getBcc())).build())
|
||||
.configurationSetName(settings.value("configurationSet"))
|
||||
.content(EmailContent.builder().raw(RawMessage.builder().data(SdkBytes.fromByteArray(bytes.toByteArray())).build()).build()).build();
|
||||
try(SesV2Client client = clientFactory.apply(builder))
|
||||
{
|
||||
return (new EmailTransportResult(client.sendEmail(request).messageId()));
|
||||
}
|
||||
}
|
||||
catch(EmailTransportException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(SesV2Exception e)
|
||||
{
|
||||
String code = e.awsErrorDetails() == null ? null : e.awsErrorDetails().errorCode();
|
||||
boolean clientFailure = e.statusCode() >= 400 && e.statusCode() < 500 && e.statusCode() != 429 && !e.isThrottlingException();
|
||||
boolean permanent = clientFailure && e.statusCode() != 401 && e.statusCode() != 403
|
||||
&& (e instanceof MessageRejectedException || "MessageRejected".equals(code) || "InvalidParameterValue".equals(code));
|
||||
throw (new EmailTransportException(permanent ? "SES rejected the message" : "SES temporarily unavailable", permanent)
|
||||
.withProviderLevel(clientFailure && !permanent));
|
||||
}
|
||||
catch(AddressException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new EmailTransportException("SES temporarily unavailable", false).withProviderLevel(credentialsFailed.get()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SES envelope destinations are individual mailboxes, without display names.
|
||||
*******************************************************************************/
|
||||
private static List<String> destinations(List<String> values) throws AddressException
|
||||
{
|
||||
List<String> addresses = new ArrayList<>();
|
||||
for(String value : values)
|
||||
{
|
||||
for(InternetAddress address : InternetAddress.parse(value, true))
|
||||
{
|
||||
addresses.add(address.getAddress());
|
||||
}
|
||||
}
|
||||
return (addresses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import com.sun.mail.smtp.SMTPAddressFailedException;
|
||||
import com.sun.mail.smtp.SMTPSendFailedException;
|
||||
import jakarta.mail.AuthenticationFailedException;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated or relay SMTP with bounded connect, read and write timeouts.
|
||||
*******************************************************************************/
|
||||
public class SmtpEmailTransport implements EmailTransport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Submit one claimed message. Provider diagnostics are never retained.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException
|
||||
{
|
||||
if(!settings.allowsSmtpAuth())
|
||||
{
|
||||
throw (new EmailTransportException("SMTP sign-in needs STARTTLS or TLS", false).withProviderLevel(true));
|
||||
}
|
||||
if("GMAIL_SMTP".equals(settings.value("kind"))
|
||||
&& (settings.value("usernameEnvName") == null || settings.value("secretEnvName") == null
|
||||
|| (settings.value("security") != null && !"STARTTLS".equals(settings.value("security")))))
|
||||
{
|
||||
throw (new EmailTransportException("Gmail SMTP needs both credential names and STARTTLS", false).withProviderLevel(true));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("mail.smtp.connectiontimeout", "10000");
|
||||
properties.setProperty("mail.smtp.timeout", "15000");
|
||||
properties.setProperty("mail.smtp.writetimeout", "15000");
|
||||
properties.setProperty("mail.smtp.auth", String.valueOf(settings.value("usernameEnvName") != null));
|
||||
properties.setProperty("mail.smtp.starttls.enable", String.valueOf("STARTTLS".equals(settings.security())));
|
||||
properties.setProperty("mail.smtp.starttls.required", String.valueOf("STARTTLS".equals(settings.security())));
|
||||
properties.setProperty("mail.smtp.ssl.enable", String.valueOf("TLS".equals(settings.security())));
|
||||
properties.setProperty("mail.smtp.ssl.checkserveridentity", "true");
|
||||
Session session = Session.getInstance(properties);
|
||||
MimeMessage message;
|
||||
try
|
||||
{
|
||||
message = EmailMime.build(session, envelope);
|
||||
}
|
||||
catch(MessagingException | RuntimeException e)
|
||||
{
|
||||
throw (new EmailTransportException("Invalid email message", true));
|
||||
}
|
||||
try(Transport transport = session.getTransport("smtp"))
|
||||
{
|
||||
String username = settings.resolve("usernameEnvName");
|
||||
String password = username == null ? null : settings.resolve("secretEnvName");
|
||||
transport.connect(settings.host(), settings.port(), username, password);
|
||||
transport.sendMessage(message, message.getAllRecipients());
|
||||
return (new EmailTransportResult(message.getMessageID()));
|
||||
}
|
||||
catch(MessagingException e)
|
||||
{
|
||||
boolean permanent = permanent(e);
|
||||
throw (new EmailTransportException(permanent ? "SMTP rejected the message" : "SMTP temporarily unavailable", permanent).withProviderLevel(providerLevel(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect chained SMTP reply codes without exposing addresses or diagnostics.
|
||||
*******************************************************************************/
|
||||
static boolean permanent(Throwable error)
|
||||
{
|
||||
if(providerLevel(error))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Set<Throwable> seen = new HashSet<>();
|
||||
while(error != null && seen.add(error))
|
||||
{
|
||||
if(error instanceof SMTPAddressFailedException address && address.getReturnCode() >= 500)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(error instanceof SMTPSendFailedException send && send.getReturnCode() >= 500)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
error = error instanceof MessagingException messaging && messaging.getNextException() != null ? messaging.getNextException() : error.getCause();
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication failures affect every message, including temporary refusals.
|
||||
*******************************************************************************/
|
||||
static boolean providerLevel(Throwable error)
|
||||
{
|
||||
Set<Throwable> seen = new HashSet<>();
|
||||
while(error != null && seen.add(error))
|
||||
{
|
||||
if(error instanceof AuthenticationFailedException
|
||||
|| error instanceof SMTPSendFailedException send && (send.getReturnCode() == 530 || send.getReturnCode() == 535)
|
||||
|| error instanceof SMTPAddressFailedException address && (address.getReturnCode() == 530 || address.getReturnCode() == 535))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
error = error instanceof MessagingException messaging && messaging.getNextException() != null ? messaging.getNextException() : error.getCause();
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetup;
|
||||
import com.kingsrook.qbits.email.delivery.*;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import com.kingsrook.qbits.email.transport.*;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
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.*;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.*;
|
||||
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.code.QCodeReference;
|
||||
import jakarta.mail.Multipart;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Delivery Test.
|
||||
*******************************************************************************/
|
||||
class EmailDeliveryTest
|
||||
{
|
||||
GreenMail mail;
|
||||
Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now, ZoneOffset.UTC));
|
||||
H2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
mail = new GreenMail(new ServerSetup(0, "127.0.0.1", "smtp"));
|
||||
mail.start();
|
||||
H2Support.provider(mail.getSmtp().getPort());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
if(mail != null)
|
||||
{
|
||||
mail.stop();
|
||||
}
|
||||
QContext.clear();
|
||||
EmailRuntime.setClock(null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Request.
|
||||
*******************************************************************************/
|
||||
EmailRequest request()
|
||||
{
|
||||
return (new EmailRequest()
|
||||
.withTo("Person <person@example.com>")
|
||||
.withSubject("Hello")
|
||||
.withMarkdownBody("# Hello\n\nA **message**."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Message.
|
||||
*******************************************************************************/
|
||||
QRecord message(Integer id) throws Exception
|
||||
{
|
||||
return (EmailStore.get(EmailTables.MESSAGE, id, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fake.
|
||||
*******************************************************************************/
|
||||
EmailDispatcher fake(EmailTransport transport)
|
||||
{
|
||||
return (new EmailDispatcher(new EmailTransportRegistry().withTransport("SMTP", transport)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing credentials and SMTP 535 pause a provider until credentials recover.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void providerCredentialsRecoverWithoutExhaustingMessageBudget(boolean refused) throws Exception
|
||||
{
|
||||
mail.setUser("person@example.com", "login", "password");
|
||||
EmailRuntime.config().withMaxAttempts(1).withSecretResolver(name -> refused ? "wrong" : null);
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1)
|
||||
.withValue("usernameEnvName", "EMAIL_SMTP_USERNAME").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD"));
|
||||
var first = EmailService.send(request());
|
||||
var second = EmailService.send(request());
|
||||
var dispatcher = new EmailDispatcher();
|
||||
for(int i = 0; i < 2; i++)
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(i * 301L), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.runOnce()).isEqualTo(new EmailDispatcher.RunSummary(0, 1, 1));
|
||||
assertThat(message(first.messageId()).getValueString("status")).isEqualTo("RETRY_WAIT");
|
||||
assertThat(message(first.messageId()).getValueInteger("attemptCount")).isEqualTo(i + 1);
|
||||
assertThat(message(first.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(i * 301L + (i == 0 ? 60 : 300)));
|
||||
assertThat(message(second.messageId()).getValueInteger("attemptCount")).isZero();
|
||||
var provider = H2Support.all(EmailTables.PROVIDER).getFirst();
|
||||
assertThat(provider.getValueString("healthStatus")).isEqualTo("UNHEALTHY");
|
||||
assertThat(provider.getValueString("lastFailureMessage")).isEqualTo("Credentials missing or refused");
|
||||
}
|
||||
EmailRuntime.config().withSecretResolver(name -> name.endsWith("USERNAME") ? "login" : "password");
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(602), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.runOnce().sent()).isEqualTo(2);
|
||||
assertThat(message(first.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER).getFirst().getValueString("healthStatus")).isEqualTo("HEALTHY");
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).extracting(row -> row.getValueString("outcome"))
|
||||
.containsExactly("PROVIDER_FAILURE", "PROVIDER_FAILURE", "SUCCEEDED", "SUCCEEDED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary transient failures still have their full budget after provider repair.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerFailuresDoNotConsumeLaterTransientBudget() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withMaxAttempts(2);
|
||||
var queued = EmailService.send(request());
|
||||
var broken = fake((e, settings) ->
|
||||
{
|
||||
throw new EmailTransportException("credentials", false).withProviderLevel(true);
|
||||
});
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(i * 901L), ZoneOffset.UTC));
|
||||
assertThat(broken.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
}
|
||||
var temporary = fake((e, settings) ->
|
||||
{
|
||||
throw new EmailTransportException("temporary", false);
|
||||
});
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(2703), ZoneOffset.UTC));
|
||||
assertThat(temporary.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(3004), ZoneOffset.UTC));
|
||||
assertThat(temporary.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
|
||||
assertThat(message(queued.messageId()).getValueInteger("attemptCount")).isEqualTo(5);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both lease recovery paths exclude earlier provider failures from the budget.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void leaseRecoveryExcludesProviderFailures(boolean maintenance) throws Exception
|
||||
{
|
||||
EmailRuntime.config().withMaxAttempts(2);
|
||||
var queued = EmailService.send(request());
|
||||
for(int i = 0; i < 2; i++)
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(i * 301L), ZoneOffset.UTC));
|
||||
assertThat(fake((e, settings) ->
|
||||
{
|
||||
throw new EmailTransportException("credentials", false).withProviderLevel(true);
|
||||
}).sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
}
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(602), ZoneOffset.UTC));
|
||||
var slow = fake((e, settings) ->
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(903), ZoneOffset.UTC));
|
||||
var recovery = fake((envelope, provider) -> new EmailTransportResult("recovered"));
|
||||
try
|
||||
{
|
||||
assertThat((maintenance ? recovery.maintain() : recovery.runOnce()).sent()).isEqualTo(1);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
return (new EmailTransportResult("late"));
|
||||
});
|
||||
assertThat(slow.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.NOT_SENT);
|
||||
assertThat(message(queued.messageId()).getValueString("providerMessageId")).isEqualTo("recovered");
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).extracting(row -> row.getValueString("outcome"))
|
||||
.containsExactly("PROVIDER_FAILURE", "PROVIDER_FAILURE", "LEASE_EXPIRED", "SUCCEEDED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Even a slow tick stops the failed provider while processing another provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerFailureStopsTheTickAfterProbeWindowPasses() throws Exception
|
||||
{
|
||||
EmailService.send(request());
|
||||
EmailService.send(request());
|
||||
H2Support.insert(EmailTables.PROVIDER, new QRecord().withValue("name", "other").withValue("kind", "SMTP")
|
||||
.withValue("host", "localhost").withValue("port", 25).withValue("fromAddress", "sender@example.com")
|
||||
.withValue("enabled", true).withValue("isDefault", false));
|
||||
var other = EmailService.send(request().withProviderName("other"));
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
var dispatcher = new EmailDispatcher(new EmailTransportRegistry().withTransport("SMTP", (e, settings) ->
|
||||
{
|
||||
calls.incrementAndGet();
|
||||
if("relay".equals(settings.value("name")))
|
||||
{
|
||||
throw new EmailTransportException("credentials", false).withProviderLevel(true);
|
||||
}
|
||||
return (new EmailTransportResult("ok"));
|
||||
}))
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Advance time only after the failed attempt has committed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected SendResult attempt(Integer id, boolean immediate) throws QException
|
||||
{
|
||||
SendResult result = super.attempt(id, immediate);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(601), ZoneOffset.UTC));
|
||||
return (result);
|
||||
}
|
||||
};
|
||||
assertThat(dispatcher.runOnce()).isEqualTo(new EmailDispatcher.RunSummary(1, 1, 1));
|
||||
assertThat(calls.get()).isEqualTo(2);
|
||||
assertThat(message(other.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Corrupt queued MIME is dead-lettered and does not prevent the next delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void messageBuildFailureIsPermanent() throws Exception
|
||||
{
|
||||
var invalidAddress = EmailService.send(request());
|
||||
var invalidSubject = EmailService.send(request());
|
||||
var valid = EmailService.send(request());
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", invalidAddress.messageId()).withValue("fromAddress", "broken <"));
|
||||
SystemWrites.updateOne(EmailTables.MESSAGE, new QRecord().withValue("id", invalidSubject.messageId()).withValue("subject", "bad\r\nheader"));
|
||||
assertThat(new EmailDispatcher().runOnce()).isEqualTo(new EmailDispatcher.RunSummary(1, 2, 0));
|
||||
assertThat(message(invalidAddress.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER");
|
||||
assertThat(message(invalidSubject.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER");
|
||||
assertThat(message(valid.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A database or runtime failure on one message cannot abort a batch.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void isolatesCheckedAndRuntimeAttemptFailures() throws Exception
|
||||
{
|
||||
var checked = EmailService.send(request());
|
||||
var unchecked = EmailService.send(request());
|
||||
var valid = EmailService.send(request());
|
||||
var dispatcher = new EmailDispatcher()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Inject failures at the same boundary as database claim/completion errors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected SendResult attempt(Integer id, boolean immediate) throws QException
|
||||
{
|
||||
if(id.equals(checked.messageId()))
|
||||
{
|
||||
throw new QException("test database failure");
|
||||
}
|
||||
if(id.equals(unchecked.messageId()))
|
||||
{
|
||||
throw new IllegalStateException("test runtime failure");
|
||||
}
|
||||
return (super.attempt(id, immediate));
|
||||
}
|
||||
};
|
||||
assertThat(dispatcher.runOnce()).isEqualTo(new EmailDispatcher.RunSummary(1, 2, 0));
|
||||
assertThat(message(valid.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both unhealthy raises and recovery resolutions tolerate a throwing sink.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failingAlertSinkCannotFailCommittedOutcomes() throws Exception
|
||||
{
|
||||
ThrowingAlertSink.raised = 0;
|
||||
ThrowingAlertSink.resolved = 0;
|
||||
AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
|
||||
.withSink(new QCodeReference(ThrowingAlertSink.class));
|
||||
var queued = EmailService.send(request());
|
||||
assertThat(fake((e, settings) ->
|
||||
{
|
||||
throw new EmailTransportException("credentials", false).withProviderLevel(true);
|
||||
}).sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
assertThat(ThrowingAlertSink.raised).isEqualTo(1);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(301), ZoneOffset.UTC));
|
||||
assertThat(new EmailDispatcher().sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.SENT);
|
||||
assertThat(ThrowingAlertSink.resolved).isEqualTo(1);
|
||||
assertThat(message(queued.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
var rejected = EmailService.send(request());
|
||||
assertThat(fake((e, settings) ->
|
||||
{
|
||||
throw new EmailTransportException("rejected", true);
|
||||
}).sendNow(rejected.messageId())).isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
|
||||
assertThat(ThrowingAlertSink.raised).isEqualTo(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Loadable sink that records invocation before simulating an alert outage.
|
||||
*******************************************************************************/
|
||||
public static class ThrowingAlertSink implements AlertSinkInterface
|
||||
{
|
||||
static int raised;
|
||||
static int resolved;
|
||||
|
||||
/*******************************************************************************
|
||||
** Simulate a failed alert raise.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(AlertSignal signal)
|
||||
{
|
||||
raised++;
|
||||
throw new IllegalStateException("test alert outage");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Simulate a failed recovery resolution.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(AlertResolution resolution)
|
||||
{
|
||||
resolved++;
|
||||
throw new IllegalStateException("test alert outage");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Heartbeats are not used by email delivery.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String source, Instant timestamp)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Smtp End To End And Idempotency.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void smtpEndToEndAndIdempotency() throws Exception
|
||||
{
|
||||
var queued = EmailService.send(request().withIdempotencyKey("one"));
|
||||
assertThat(queued.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(mail.getReceivedMessages()).isEmpty();
|
||||
var duplicate = EmailService.send(request().withIdempotencyKey("one"));
|
||||
assertThat(duplicate.status()).isEqualTo(EmailSendResult.Status.DUPLICATE);
|
||||
assertThat(duplicate.messageId()).isEqualTo(queued.messageId());
|
||||
assertThat(new EmailDispatcher().runOnce().sent()).isEqualTo(1);
|
||||
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
|
||||
var received = mail.getReceivedMessages()[0];
|
||||
Multipart parts = (Multipart) received.getContent();
|
||||
assertThat(parts.getCount()).isEqualTo(2);
|
||||
assertThat(parts.getBodyPart(0).getContent().toString()).contains("Hello", "message");
|
||||
assertThat(parts.getBodyPart(1).getContent().toString()).contains("<strong>message</strong>");
|
||||
assertThat(received.getHeader("Auto-Submitted")).containsExactly("auto-generated");
|
||||
assertThat(received.getHeader("X-QQQ-Email-Id")).containsExactly(queued.messageId().toString());
|
||||
assertThat(message(queued.messageId()).getValueString("providerMessageId"))
|
||||
.isEqualTo(received.getMessageID());
|
||||
assertThat(message(queued.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Smtp Authentication And Secret Resolution Only At Send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void smtpAuthenticationAndSecretResolutionOnlyAtSend() throws Exception
|
||||
{
|
||||
mail.setUser("person@example.com", "login", "password");
|
||||
AtomicInteger resolutions = new AtomicInteger();
|
||||
EmailRuntime.config()
|
||||
.withSecretResolver(
|
||||
name ->
|
||||
{
|
||||
resolutions.incrementAndGet();
|
||||
return (name.endsWith("USERNAME") ? "login" : "password");
|
||||
});
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER,
|
||||
new QRecord()
|
||||
.withValue("id", 1)
|
||||
.withValue("usernameEnvName", "EMAIL_SMTP_USERNAME")
|
||||
.withValue("secretEnvName", "EMAIL_SMTP_PASSWORD"));
|
||||
var queued = EmailService.send(request());
|
||||
assertThat(resolutions.get()).isZero();
|
||||
assertThat(new EmailDispatcher().sendNow(queued.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.SENT);
|
||||
assertThat(resolutions.get()).isEqualTo(2);
|
||||
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unreachable Smtp Retries.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unreachableSmtpRetries() throws Exception
|
||||
{
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("port", port));
|
||||
var queued = EmailService.send(request());
|
||||
assertThat(new EmailDispatcher().sendNow(queued.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.RETRY_WAIT);
|
||||
assertThat(message(queued.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(60));
|
||||
assertThat(message(queued.messageId()).getValueString("lastError"))
|
||||
.doesNotContain("person", "Hello");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retries Backoff Max Attempts And Retry After.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retriesBackoffMaxAttemptsAndRetryAfter() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withMaxAttempts(3);
|
||||
var queued = EmailService.send(request());
|
||||
var dispatcher =
|
||||
fake(
|
||||
(e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("secret diagnostic", false);
|
||||
});
|
||||
assertThat(dispatcher.runOnce().failed()).isEqualTo(1);
|
||||
assertThat(message(queued.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(60));
|
||||
assertThat(dispatcher.runOnce().failed()).isZero();
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(60), ZoneOffset.UTC));
|
||||
dispatcher.runOnce();
|
||||
assertThat(message(queued.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(360));
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(360), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.runOnce().failed()).isEqualTo(1);
|
||||
assertThat(message(queued.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER");
|
||||
var second = EmailService.send(request());
|
||||
fake((e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("throttle", false).withRetryAfter(Duration.ofHours(2));
|
||||
})
|
||||
.runOnce();
|
||||
assertThat(message(second.messageId()).getValueInstant("nextAttemptDate"))
|
||||
.isEqualTo(now.plusSeconds(3960));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Permanent Failure And Future Transport Are Dead Letters.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permanentFailureAndFutureTransportAreDeadLetters() throws Exception
|
||||
{
|
||||
var queued = EmailService.send(request());
|
||||
assertThat(
|
||||
fake((e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("rejected", true);
|
||||
})
|
||||
.sendNow(queued.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
|
||||
var future = EmailService.send(request());
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("kind", "GMAIL_API"));
|
||||
assertThat(new EmailDispatcher().sendNow(future.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.DEAD_LETTER);
|
||||
assertThatThrownBy(() -> new EmailTransportRegistry().get("GMAIL_API"))
|
||||
.hasMessage("Gmail API sending is specified but not built; use GMAIL_SMTP");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired Lease Retries And Stale Completion Cannot Overwrite.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void expiredLeaseRetriesAndStaleCompletionCannotOverwrite() throws Exception
|
||||
{
|
||||
var queued = EmailService.send(request());
|
||||
var recovery = fake((e, s) -> new EmailTransportResult("recovered"));
|
||||
var slow =
|
||||
fake(
|
||||
(e, s) ->
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(121), ZoneOffset.UTC));
|
||||
try
|
||||
{
|
||||
assertThat(recovery.runOnce().sent()).isEqualTo(1);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
return (new EmailTransportResult("late"));
|
||||
});
|
||||
assertThat(slow.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.NOT_SENT);
|
||||
assertThat(message(queued.messageId()).getValueString("providerMessageId"))
|
||||
.isEqualTo("recovered");
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT))
|
||||
.extracting(r -> r.getValueString("outcome"))
|
||||
.containsExactly("LEASE_EXPIRED", "SUCCEEDED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppression Scope Category Expiry And Partial.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void suppressionScopeCategoryExpiryAndPartial() throws Exception
|
||||
{
|
||||
H2Support.insert(
|
||||
EmailTables.SUPPRESSION,
|
||||
new QRecord()
|
||||
.withValue("address", "PERSON@example.com")
|
||||
.withValue("scope", "ALL")
|
||||
.withValue("reason", "HARD_BOUNCE"));
|
||||
var suppressed = EmailService.send(request());
|
||||
assertThat(suppressed.status()).isEqualTo(EmailSendResult.Status.SUPPRESSED);
|
||||
assertThat(new EmailDispatcher().runOnce().sent()).isZero();
|
||||
var partial = EmailService.send(request().withCc("other@example.com"));
|
||||
assertThat(partial.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(message(partial.messageId()).getValueString("toAddresses")).isEmpty();
|
||||
assertThat(message(partial.messageId()).getValueString("suppressedAddresses"))
|
||||
.contains("person@example.com");
|
||||
SystemWrites.delete(
|
||||
EmailTables.SUPPRESSION,
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter());
|
||||
H2Support.insert(
|
||||
EmailTables.SUPPRESSION,
|
||||
new QRecord()
|
||||
.withValue("address", "person@example.com")
|
||||
.withValue("scope", "NOTIFICATION")
|
||||
.withValue("reason", "UNSUBSCRIBE"));
|
||||
assertThat(EmailService.send(request()).status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(EmailService.send(request().withCategory(EmailCategory.NOTIFICATION)).status())
|
||||
.isEqualTo(EmailSendResult.Status.SUPPRESSED);
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.SUPPRESSION, new QRecord().withValue("id", 2).withValue("scope", "ALL"));
|
||||
assertThat(EmailService.send(request().withCategory(EmailCategory.SECURITY)).status())
|
||||
.isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.SUPPRESSION,
|
||||
new QRecord().withValue("id", 2).withValue("expiresDate", now.minusSeconds(1)));
|
||||
assertThat(EmailService.send(request().withCategory(EmailCategory.MARKETING)).status())
|
||||
.isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rate Limits Do Not Consume Attempts And Send After Is Honored.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rateLimitsDoNotConsumeAttemptsAndSendAfterIsHonored() throws Exception
|
||||
{
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER,
|
||||
new QRecord().withValue("id", 1).withValue("maxPerMinute", 1).withValue("maxPerDay", 2));
|
||||
EmailService.send(request());
|
||||
EmailService.send(request());
|
||||
var deferred = EmailService.send(request().withSendAfter(now.plusSeconds(1000)));
|
||||
var dispatcher = fake((e, s) -> new EmailTransportResult("ok"));
|
||||
assertThat(dispatcher.runOnce().sent()).isEqualTo(1);
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(1);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(61), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.runOnce().sent()).isEqualTo(1);
|
||||
assertThat(dispatcher.sendNow(deferred.messageId()))
|
||||
.isEqualTo(EmailDispatcher.SendResult.NOT_SENT);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transaction Commit Rollback And Cross Backend Callback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void transactionCommitRollbackAndCrossBackendCallback() throws Exception
|
||||
{
|
||||
try(var tx = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
assertThat(EmailService.send(request().withTransaction(tx, "rdbms")).status())
|
||||
.isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
tx.rollback();
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
try(var tx = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
EmailService.send(request().withTransaction(tx, "rdbms"));
|
||||
tx.commit();
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
var other = new QBackendTransaction();
|
||||
var result = EmailService.send(request().withTransaction(other, "elsewhere"));
|
||||
assertThat(result.messageId()).isNull();
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
other.commit();
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(2);
|
||||
var rolledBack = new QBackendTransaction();
|
||||
EmailService.send(request().withTransaction(rolledBack, "elsewhere"));
|
||||
rolledBack.rollback();
|
||||
rolledBack.commit();
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge Core Action Queues And Delivers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bridgeCoreActionQueuesAndDelivers() throws Exception
|
||||
{
|
||||
new SendMessageAction()
|
||||
.execute(
|
||||
new SendMessageInput()
|
||||
.withMessagingProviderName("qbitEmail")
|
||||
.withSubject("Bridge")
|
||||
.withTo(
|
||||
new MultiParty()
|
||||
.withParty(
|
||||
new Party()
|
||||
.withAddress("person@example.com")
|
||||
.withRole(EmailPartyRole.TO))
|
||||
.withParty(
|
||||
new Party()
|
||||
.withAddress("copy@example.com")
|
||||
.withRole(EmailPartyRole.CC)))
|
||||
.withFrom(new Party().withAddress("sender@example.com").withLabel("Sender"))
|
||||
.withContent(
|
||||
new Content().withContentRole(EmailContentRole.HTML).withBody("<p>Bridge</p>"))
|
||||
.withContent(
|
||||
new Content().withContentRole(EmailContentRole.TEXT).withBody("Bridge")));
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE))
|
||||
.hasSize(1)
|
||||
.allMatch(r -> "QUEUED".equals(r.getValueString("status")));
|
||||
assertThat(mail.getReceivedMessages()).isEmpty();
|
||||
assertThat(new EmailDispatcher().runOnce().sent()).isEqualTo(1);
|
||||
assertThat(mail.waitForIncomingEmail(2000, 2)).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider Health Ten Failures Probe And Recovery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerHealthTenFailuresProbeAndRecovery() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withMaxAttempts(20);
|
||||
for(int i = 0; i < 11; i++)
|
||||
{
|
||||
EmailService.send(request());
|
||||
}
|
||||
var dispatcher =
|
||||
fake(
|
||||
(e, s) ->
|
||||
{
|
||||
throw new EmailTransportException("temporary", false);
|
||||
});
|
||||
assertThat(dispatcher.runOnce().failed()).isEqualTo(10);
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER).getFirst().getValueString("healthStatus"))
|
||||
.isEqualTo("UNHEALTHY");
|
||||
assertThat(dispatcher.runOnce().failed()).isZero();
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(301), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.maintain().failed()).isEqualTo(1);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(602), ZoneOffset.UTC));
|
||||
assertThat(fake((e, s) -> new EmailTransportResult("ok")).runOnce().sent()).isGreaterThan(0);
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER).getFirst().getValueString("healthStatus"))
|
||||
.isEqualTo("HEALTHY");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention Keeps Digest And Unfinished Messages.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retentionKeepsDigestAndUnfinishedMessages() throws Exception
|
||||
{
|
||||
var sent = EmailService.send(request());
|
||||
fake((e, s) -> new EmailTransportResult("ok")).sendNow(sent.messageId());
|
||||
var waiting = EmailService.send(request());
|
||||
EmailRuntime.setClock(Clock.fixed(now.plus(Duration.ofDays(31)), ZoneOffset.UTC));
|
||||
EmailProcesses.retain();
|
||||
assertThat(message(sent.messageId()).getValueString("htmlBody")).isNull();
|
||||
assertThat(message(sent.messageId()).getValueString("bodySha256")).hasSize(64);
|
||||
assertThat(message(waiting.messageId()).getValueString("htmlBody")).isNotNull();
|
||||
EmailRuntime.config().withMessageRetentionDays(30);
|
||||
EmailProcesses.retain();
|
||||
assertThat(message(sent.messageId())).isNull();
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Two Replicas Claim Each Message Once And Share Rate Budget.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void twoReplicasClaimEachMessageOnceAndShareRateBudget() throws Exception
|
||||
{
|
||||
var instanceA = QContext.getQInstance();
|
||||
var backend =
|
||||
(com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData)
|
||||
instanceA.getBackend("rdbms");
|
||||
var instanceB = H2Support.instance(backend, c ->
|
||||
{
|
||||
});
|
||||
for(int i = 0; i < 25; i++)
|
||||
{
|
||||
EmailService.send(request());
|
||||
}
|
||||
var received = new ConcurrentHashMap<String, AtomicInteger>();
|
||||
var ready = new CyclicBarrier(2);
|
||||
try(var pool = Executors.newFixedThreadPool(2))
|
||||
{
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for(var instance : List.of(instanceA, instanceB))
|
||||
{
|
||||
futures.add(
|
||||
pool.submit(
|
||||
() ->
|
||||
{
|
||||
QContext.init(instance, H2Support.admin());
|
||||
try
|
||||
{
|
||||
ready.await();
|
||||
var worker =
|
||||
fake(
|
||||
(e, s) ->
|
||||
{
|
||||
received
|
||||
.computeIfAbsent(e.getMessageId(), k -> new AtomicInteger())
|
||||
.incrementAndGet();
|
||||
return (new EmailTransportResult("ok"));
|
||||
});
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
worker.runOnce();
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for(var future : futures)
|
||||
{
|
||||
future.get(30, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
assertThat(received).hasSize(25);
|
||||
assertThat(received.values()).allMatch(n -> n.get() == 1);
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(25);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concurrent Idempotency Joins Caller Transactions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void concurrentIdempotencyJoinsCallerTransactions() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
try(var pool = Executors.newFixedThreadPool(4))
|
||||
{
|
||||
var barrier = new CyclicBarrier(4);
|
||||
List<Future<EmailSendResult>> results = new ArrayList<>();
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
results.add(
|
||||
pool.submit(
|
||||
() ->
|
||||
{
|
||||
QContext.init(instance, H2Support.admin());
|
||||
try(var tx = QBackendTransaction.openFor(new InsertInput(EmailTables.MESSAGE)))
|
||||
{
|
||||
barrier.await();
|
||||
var result =
|
||||
EmailService.send(
|
||||
request()
|
||||
.withIdempotencyKey("concurrent")
|
||||
.withTransaction(tx, "rdbms"));
|
||||
tx.commit();
|
||||
return (result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
List<EmailSendResult> outcomes = new ArrayList<>();
|
||||
for(var f : results)
|
||||
{
|
||||
outcomes.add(f.get(20, TimeUnit.SECONDS));
|
||||
}
|
||||
assertThat(outcomes).filteredOn(r -> r.status() == EmailSendResult.Status.QUEUED).hasSize(1);
|
||||
assertThat(outcomes)
|
||||
.filteredOn(r -> r.status() == EmailSendResult.Status.DUPLICATE)
|
||||
.hasSize(3);
|
||||
assertThat(outcomes.stream().map(EmailSendResult::messageId).distinct()).hasSize(1);
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replicas Share Rate Limit And Limited Provider Does Not Starve Others.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void replicasShareRateLimitAndLimitedProviderDoesNotStarveOthers() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withBatchSize(1);
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("maxPerMinute", 1));
|
||||
var other =
|
||||
H2Support.insert(
|
||||
EmailTables.PROVIDER,
|
||||
new QRecord()
|
||||
.withValue("name", "other")
|
||||
.withValue("kind", "SMTP")
|
||||
.withValue("host", "127.0.0.1")
|
||||
.withValue("port", 25)
|
||||
.withValue("fromAddress", "sender@example.com")
|
||||
.withValue("isDefault", false)
|
||||
.withValue("enabled", true));
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
EmailService.send(request());
|
||||
}
|
||||
var otherMessage = EmailService.send(request().withProviderName("other"));
|
||||
var instance = QContext.getQInstance();
|
||||
try(var pool = Executors.newFixedThreadPool(2))
|
||||
{
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for(int i = 0; i < 2; i++)
|
||||
{
|
||||
futures.add(
|
||||
pool.submit(
|
||||
() ->
|
||||
{
|
||||
QContext.init(instance, H2Support.admin());
|
||||
try
|
||||
{
|
||||
fake((e, settings) -> new EmailTransportResult("ok")).runOnce();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for(var f : futures)
|
||||
{
|
||||
f.get(20, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).hasSize(2);
|
||||
assertThat(message(otherMessage.messageId()).getValueString("status")).isEqualTo("SENT");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Maintenance Recovers Disabled Provider Lease And Exhausted Attempts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void maintenanceRecoversDisabledProviderLeaseAndExhaustedAttempts() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withMaxAttempts(1);
|
||||
var queued = EmailService.send(request());
|
||||
var inFlight =
|
||||
fake(
|
||||
(e, settings) ->
|
||||
{
|
||||
try
|
||||
{
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.PROVIDER,
|
||||
new QRecord().withValue("id", 1).withValue("enabled", false));
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(121), ZoneOffset.UTC));
|
||||
new EmailDispatcher().maintain();
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
return (new EmailTransportResult("too-late"));
|
||||
});
|
||||
assertThat(inFlight.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.NOT_SENT);
|
||||
assertThat(message(queued.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER");
|
||||
assertThat(message(queued.messageId()).getValueInstant("finishedDate"))
|
||||
.isEqualTo(now.plusSeconds(121));
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT).getFirst().getValueString("outcome"))
|
||||
.isEqualTo("LEASE_EXPIRED");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.util.*;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.metadata.*;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.permissions.*;
|
||||
import com.kingsrook.qbits.email.processes.*;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.*;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Metadata Test.
|
||||
*******************************************************************************/
|
||||
class EmailMetadataTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Teardown.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void teardown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer Standards And Integration Fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producerStandardsAndIntegrationFields() throws Exception
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
assertThat(instance.getTables()).hasSize(7);
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
assertThat(table.getName()).matches("email_[a-z_]+");
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).isEqualTo("email");
|
||||
assertThat(table.getBackendName()).isEqualTo("rdbms");
|
||||
assertThat(table.getIsHidden()).isFalse();
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
assertThat(field.getBackendName()).matches("[a-z][a-z0-9_]*");
|
||||
}
|
||||
}
|
||||
var children = instance.getApp("email").getChildren();
|
||||
assertThat(children).extracting(c -> c.getName()).containsExactlyElementsOf(EmailTables.MAIN);
|
||||
assertThat(EmailTables.SYSTEM_WRITTEN)
|
||||
.allSatisfy(
|
||||
t ->
|
||||
assertThat(instance.getTable(t).getDisabledCapabilities())
|
||||
.contains(
|
||||
Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE));
|
||||
assertThat(instance.getMessagingProvider("qbitEmail").getType()).isEqualTo("QBIT_EMAIL");
|
||||
assertThat(EmailIntegrations.esbTableNames())
|
||||
.containsExactly(EmailTables.MESSAGE, EmailTables.TEMPLATE, EmailTables.SUPPRESSION);
|
||||
assertThat(EmailIntegrations.apiTableNames()).isEqualTo(EmailIntegrations.esbTableNames());
|
||||
assertThat(EmailIntegrations.quickSearchTables())
|
||||
.allSatisfy(
|
||||
t ->
|
||||
assertThat(t.fieldWeights())
|
||||
.doesNotContainKeys("subject", "htmlBody", "textBody", "variablesSnapshot"));
|
||||
assertThat(
|
||||
new EmailApp.HomeRenderer()
|
||||
.render(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()))
|
||||
.isNotNull();
|
||||
assertThat(EmailRuntime.isInstalled()).isTrue();
|
||||
assertThat(EmailRuntime.config(new QInstance())).isNull();
|
||||
assertThat(EmailRuntime.config(null)).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider.
|
||||
*******************************************************************************/
|
||||
QRecord provider(String name)
|
||||
{
|
||||
return (new QRecord()
|
||||
.withValue("name", name)
|
||||
.withValue("kind", "SMTP")
|
||||
.withValue("host", "localhost")
|
||||
.withValue("port", 25)
|
||||
.withValue("fromAddress", "sender@example.com")
|
||||
.withValue("enabled", true)
|
||||
.withValue("isDefault", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert.
|
||||
*******************************************************************************/
|
||||
QRecord insert(QRecord row) throws Exception
|
||||
{
|
||||
return (new InsertAction()
|
||||
.execute(new InsertInput(EmailTables.PROVIDER).withRecords(List.of(row)))
|
||||
.getRecords()
|
||||
.get(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates Env Name Allowlist And Never Returns Values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validatesEnvNameAllowlistAndNeverReturnsValues() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withSecretResolver(n -> "top-secret-do-not-expose");
|
||||
assertThat(insert(provider("bad").withValue("secretEnvName", "literal-password")).getErrors())
|
||||
.isNotEmpty();
|
||||
assertThat(insert(provider("bad2").withValue("secretEnvName", "OTHER_SECRET")).getErrors())
|
||||
.isNotEmpty();
|
||||
assertThat(
|
||||
insert(provider("domain").withValue("sendingDomain", "different.example")).getErrors())
|
||||
.isNotEmpty();
|
||||
assertThat(insert(provider("port").withValue("port", 0)).getErrors()).isNotEmpty();
|
||||
assertThat(insert(provider("no-default").withValue("isDefault", false)).getErrors())
|
||||
.isNotEmpty();
|
||||
var good = insert(provider("relay").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD"));
|
||||
assertThat(good.getErrors()).isNullOrEmpty();
|
||||
var queried =
|
||||
new QueryAction().execute(new QueryInput(EmailTables.PROVIDER)).getRecords().getFirst();
|
||||
assertThat(queried.getValueString("secretEnvNameStatus")).isEqualTo("Set");
|
||||
assertThat(queried.getValueString("usernameEnvNameStatus")).isEqualTo("Not set");
|
||||
assertThat(queried.getValues().values().toString()).doesNotContain("top-secret");
|
||||
EmailRuntime.config()
|
||||
.withSecretResolver(
|
||||
n ->
|
||||
{
|
||||
throw new RuntimeException("secret in exception");
|
||||
});
|
||||
queried =
|
||||
new QueryAction().execute(new QueryInput(EmailTables.PROVIDER)).getRecords().getFirst();
|
||||
assertThat(queried.getValueString("secretEnvNameStatus")).isEqualTo("Not set");
|
||||
assertThat(insert(provider("duplicate-default")).getErrors()).isNotEmpty();
|
||||
assertThat(insert(provider("secondary").withValue("isDefault", false)).getErrors())
|
||||
.isNullOrEmpty();
|
||||
var updated =
|
||||
new UpdateAction()
|
||||
.execute(
|
||||
new UpdateInput(EmailTables.PROVIDER)
|
||||
.withRecords(
|
||||
List.of(
|
||||
new QRecord()
|
||||
.withValue("id", good.getValue("id"))
|
||||
.withValue("label", "New label"))));
|
||||
assertThat(updated.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
var invalid =
|
||||
new UpdateAction()
|
||||
.execute(
|
||||
new UpdateInput(EmailTables.PROVIDER)
|
||||
.withRecords(
|
||||
List.of(
|
||||
new QRecord()
|
||||
.withValue("id", good.getValue("id"))
|
||||
.withValue("enabled", false))));
|
||||
assertThat(invalid.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
var delete =
|
||||
new DeleteAction()
|
||||
.execute(
|
||||
new DeleteInput(EmailTables.PROVIDER)
|
||||
.withQueryFilter(EmailStore.equal("id", good.getValueInteger("id"))));
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER)).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service Refuses Invalid Inputs And Rendering Escapes Raw Html.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serviceRefusesInvalidInputsAndRenderingEscapesRawHtml() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
var good =
|
||||
new EmailRequest()
|
||||
.withTo("a@example.com")
|
||||
.withSubject("s")
|
||||
.withMarkdownBody("<script>alert(1)</script>\n\n**Hello**");
|
||||
assertThat(EmailService.send(new EmailRequest().withTemplateName("later")).reasons())
|
||||
.contains("templates arrive in part 2");
|
||||
assertThat(EmailService.send(null).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(EmailService.send(new EmailRequest()).status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(EmailService.send(new EmailRequest().withSubject("s").withTextBody("body")).status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(
|
||||
EmailService.send(
|
||||
new EmailRequest().withTo("not-address").withSubject("s").withTextBody("body"))
|
||||
.status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(
|
||||
EmailService.send(
|
||||
new EmailRequest()
|
||||
.withTo("a@example.com\r\nBcc:x@example.com")
|
||||
.withSubject("s")
|
||||
.withTextBody("body"))
|
||||
.status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(
|
||||
EmailService.send(
|
||||
new EmailRequest()
|
||||
.withTo("a@example.com")
|
||||
.withSubject("s")
|
||||
.withTextBody("body")
|
||||
.withVariable("phi", "no"))
|
||||
.status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
var rendered = new AdHocEmailRenderer().render(good);
|
||||
assertThat(rendered.html()).contains("<script>").doesNotContain("<script>");
|
||||
assertThat(new AdHocEmailRenderer().render(new EmailRequest().withTextBody("<safe>")).html())
|
||||
.contains("<safe>");
|
||||
assertThat(
|
||||
new AdHocEmailRenderer()
|
||||
.render(new EmailRequest().withHtmlBody("<p>Hello & bye</p>"))
|
||||
.text())
|
||||
.isEqualTo("Hello & bye");
|
||||
assertThat(EmailService.send(good.withProviderName("missing")).status())
|
||||
.isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission Checker Protects System Tables Providers And Actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionCheckerProtectsSystemTablesProvidersAndActions() throws Exception
|
||||
{
|
||||
var checker = new EmailPermissionChecker();
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
var instance = QContext.getQInstance();
|
||||
checker.checkPermissionsThrowing(
|
||||
new QueryInput(EmailTables.MESSAGE), instance.getTable(EmailTables.MESSAGE));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
checker.checkPermissionsThrowing(
|
||||
new InsertInput(EmailTables.MESSAGE), instance.getTable(EmailTables.MESSAGE)))
|
||||
.hasMessageContaining("only");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
checker.checkPermissionsThrowing(
|
||||
new QueryInput(EmailTables.PROVIDER), instance.getTable(EmailTables.PROVIDER)))
|
||||
.hasMessageContaining("email.admin");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
checker.checkPermissionsThrowing(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput(),
|
||||
instance.getProcess(EmailProcesses.SEND_TEMPLATED)))
|
||||
.hasMessageContaining("email.send");
|
||||
QContext.getQSession().setPermissions(Set.of("email.send"));
|
||||
assertThat(EmailPermissionChecker.has("email.send")).isTrue();
|
||||
assertThat(EmailPermissionChecker.has("email.view")).isFalse();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(EmailTables.MESSAGE)))
|
||||
.hasMessageContaining("email.view");
|
||||
checker.checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput(),
|
||||
instance.getProcess(EmailProcesses.SEND_TEMPLATED));
|
||||
assertThat(EmailPermissionChecker.has("email.admin")).isFalse();
|
||||
QContext.getQSession().setPermissions(Set.of("email.manage"));
|
||||
assertThat(EmailPermissionChecker.has("email.send")).isTrue();
|
||||
assertThat(EmailPermissionChecker.has("email.view")).isTrue();
|
||||
assertThat(EmailProcesses.permissionFor(EmailProcesses.SEND_TEST)).isEqualTo("email.admin");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Credentialed SMTP needs encryption unless loopback or explicitly allowed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void plaintextSmtpAuthenticationValidation()
|
||||
{
|
||||
var config = EmailRuntime.config();
|
||||
assertThat(config.getAllowPlaintextSmtpAuth()).isFalse();
|
||||
for(String kind : List.of("SMTP"))
|
||||
{
|
||||
for(String credential : List.of("usernameEnvName", "secretEnvName"))
|
||||
{
|
||||
var row = provider("auth").withValue("kind", kind).withValue("host", "relay.example.com")
|
||||
.withValue("security", "NONE").withValue(credential, "EMAIL_SMTP_USERNAME");
|
||||
assertThat(EmailTableCustomizers.validateProvider(row, config)).contains("SMTP sign-in needs STARTTLS or TLS");
|
||||
for(String security : List.of("STARTTLS", "TLS"))
|
||||
{
|
||||
assertThat(EmailTableCustomizers.validateProvider(row.withValue("security", security), config)).isEmpty();
|
||||
}
|
||||
row.withValue("security", "NONE");
|
||||
assertThat(EmailTableCustomizers.validateProvider(row, config.withAllowPlaintextSmtpAuth(true))).isEmpty();
|
||||
config.withAllowPlaintextSmtpAuth(false);
|
||||
}
|
||||
}
|
||||
for(String host : List.of("localhost", "LOCALHOST", "127.0.0.1", "::1"))
|
||||
{
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider("local").withValue("host", host)
|
||||
.withValue("security", "NONE").withValue("usernameEnvName", "EMAIL_SMTP_USERNAME"), config)).isEmpty();
|
||||
}
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider("relay").withValue("host", "relay.example.com")
|
||||
.withValue("security", "NONE"), config)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sender headers cannot inject new headers, including sparse UI updates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerHeadersRefuseLineBreaks() throws Exception
|
||||
{
|
||||
var stored = insert(provider("headers"));
|
||||
for(String field : List.of("fromName", "fromAddress", "replyTo"))
|
||||
{
|
||||
for(String newline : List.of("\r", "\n"))
|
||||
{
|
||||
var row = provider("bad").withValue(field, "sender@example.com" + newline + "Bcc: hidden@example.com");
|
||||
assertThat(EmailTableCustomizers.validateProvider(row, EmailRuntime.config())).isNotEmpty();
|
||||
var updated = new UpdateAction().execute(new UpdateInput(EmailTables.PROVIDER)
|
||||
.withRecords(List.of(new QRecord().withValue("id", stored.getValue("id")).withValue(field, row.getValue(field)))));
|
||||
assertThat(updated.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complaint Suppression Is Admin Protected On Update And Delete.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void complaintSuppressionIsAdminProtectedOnUpdateAndDelete() throws Exception
|
||||
{
|
||||
var row =
|
||||
H2Support.insert(
|
||||
EmailTables.SUPPRESSION,
|
||||
new QRecord()
|
||||
.withValue("address", "a@example.com")
|
||||
.withValue("scope", "ALL")
|
||||
.withValue("reason", "COMPLAINT"));
|
||||
QContext.getQSession().setPermissions(Set.of("email.manage", "email.hasAccess"));
|
||||
var update =
|
||||
new UpdateAction()
|
||||
.execute(
|
||||
new UpdateInput(EmailTables.SUPPRESSION)
|
||||
.withRecords(
|
||||
List.of(
|
||||
new QRecord()
|
||||
.withValue("id", row.getValue("id"))
|
||||
.withValue("reason", "MANUAL"))));
|
||||
assertThat(update.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
new DeleteAction()
|
||||
.execute(new DeleteInput(EmailTables.SUPPRESSION).withQueryFilter(new QQueryFilter()));
|
||||
assertThat(H2Support.all(EmailTables.SUPPRESSION)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration Validation And Customizers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configurationValidationAndCustomizers() throws Exception
|
||||
{
|
||||
var config =
|
||||
new EmailQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSchedulerName("missing")
|
||||
.withMaxAttempts(0)
|
||||
.withBatchSize(0)
|
||||
.withRetryMinutes(List.of())
|
||||
.withLeaseSeconds(1)
|
||||
.withBodyRetentionDays(-1)
|
||||
.withAllowedEnvNames(Set.of("bad-name"))
|
||||
.withAppName("");
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(QContext.getQInstance(), errors);
|
||||
assertThat(errors).hasSizeGreaterThanOrEqualTo(6);
|
||||
assertThat(new EmailQBitConfig().retryMinutesAfterAttempt(20)).isEqualTo(720);
|
||||
for(String kind :
|
||||
List.of(
|
||||
"SMTP",
|
||||
"SES",
|
||||
"GMAIL_SMTP",
|
||||
"POSTMARK",
|
||||
"SENDGRID",
|
||||
"MAILGUN",
|
||||
"GMAIL_API",
|
||||
"future"))
|
||||
{
|
||||
assertThat(
|
||||
new EmailQBitConfig()
|
||||
.withAllowedEnvNames(Set.of("CUSTOM"))
|
||||
.effectiveAllowedEnvNames(kind))
|
||||
.contains("CUSTOM");
|
||||
}
|
||||
H2Support.setup(
|
||||
c ->
|
||||
c.withTableMetaDataCustomizer(
|
||||
(instance, table) -> table.withLabel("Custom " + table.getLabel()))
|
||||
.withSecurityFields(List.of(new QFieldMetaData("tenantId", QFieldType.INTEGER))));
|
||||
assertThat(
|
||||
QContext.getQInstance()
|
||||
.getTable(EmailTables.MESSAGE)
|
||||
.getField("tenantId")
|
||||
.getBackendName())
|
||||
.isEqualTo("tenant_id");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core Enforcement Rejects Direct Writes But Permits Authorized Enqueue.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreEnforcementRejectsDirectWritesButPermitsAuthorizedEnqueue() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
QContext.getQInstance().setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of("email.view", "email.hasAccess"));
|
||||
var request = new EmailRequest().withTo("a@example.com").withSubject("s").withTextBody("body");
|
||||
assertThat(EmailService.send(request).status()).isEqualTo(EmailSendResult.Status.REFUSED);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new InsertAction()
|
||||
.execute(
|
||||
new InsertInput(EmailTables.MESSAGE).withRecords(List.of(new QRecord()))))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(EmailTables.PROVIDER)))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
assertThat(SystemWrites.call(() -> EmailService.send(request)).status())
|
||||
.isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
QContext.getQSession().setPermissions(Set.of("email.send", "email.hasAccess"));
|
||||
assertThat(EmailService.send(request).status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(EmailTables.MESSAGE)))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
assertThat(SystemWrites.active()).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge Refuses Attachments And Invalid Requests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bridgeRefusesAttachmentsAndInvalidRequests() throws Exception
|
||||
{
|
||||
H2Support.provider(25);
|
||||
var bridge = new com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProvider();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
bridge.sendMessage(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput()
|
||||
.withAttachment(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.messaging
|
||||
.Attachment())))
|
||||
.hasMessageContaining("attachments");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
bridge.sendMessage(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput()))
|
||||
.hasMessageContaining("subject");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled metadata uses the configured scheduler and exact intervals.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scheduledMetadataUsesConfiguredIntervals() throws Exception
|
||||
{
|
||||
QContext.getQInstance().addScheduler(new com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData().withName("scheduler"));
|
||||
EmailQBitConfig config = new EmailQBitConfig().withBackendName("rdbms").withSchedulerName("scheduler");
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(QContext.getQInstance(), errors);
|
||||
assertThat(errors).isEmpty();
|
||||
var scheduled = EmailProcesses.processes(config).stream().filter(p -> p.getSchedule() != null).toList();
|
||||
assertThat(scheduled).hasSize(3).allMatch(p -> p.getIsHidden());
|
||||
assertThat(scheduled).extracting(p -> p.getSchedule().getRepeatSeconds()).containsExactly(15, 300, 86400);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import com.icegreen.greenmail.util.*;
|
||||
import com.kingsrook.qbits.email.delivery.*;
|
||||
import com.kingsrook.qbits.email.metadata.*;
|
||||
import com.kingsrook.qbits.email.outbox.*;
|
||||
import com.kingsrook.qbits.email.processes.*;
|
||||
import com.kingsrook.qbits.email.support.*;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.*;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Processes Test.
|
||||
*******************************************************************************/
|
||||
class EmailProcessesTest
|
||||
{
|
||||
GreenMail mail;
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
mail = new GreenMail(new ServerSetup(0, "127.0.0.1", "smtp"));
|
||||
mail.start();
|
||||
H2Support.provider(mail.getSmtp().getPort());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
if(mail != null)
|
||||
{
|
||||
mail.stop();
|
||||
}
|
||||
QContext.clear();
|
||||
EmailRuntime.setClock(null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Request.
|
||||
*******************************************************************************/
|
||||
EmailRequest request()
|
||||
{
|
||||
return (new EmailRequest()
|
||||
.withTo("a@example.com")
|
||||
.withSubject("Test")
|
||||
.withMarkdownBody("Hello"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel Send Now Resend And Send Test Use Review And Result.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cancelSendNowResendAndSendTestUseReviewAndResult() throws Exception
|
||||
{
|
||||
var cancelled = EmailService.send(request());
|
||||
var cancel = ProcessRunner.run(EmailProcesses.CANCEL, cancelled.messageId());
|
||||
assertThat(cancel.review()).isNotEmpty();
|
||||
assertThat(cancel.result()).anyMatch(s -> s.contains("CANCELLED"));
|
||||
assertThat(
|
||||
EmailStore.get(EmailTables.MESSAGE, cancelled.messageId(), null)
|
||||
.getValueString("status"))
|
||||
.isEqualTo("CANCELLED");
|
||||
var queued = EmailService.send(request().withSendAfter(Instant.now().plusSeconds(3600)));
|
||||
var sent = ProcessRunner.run(EmailProcesses.SEND_NOW, queued.messageId());
|
||||
assertThat(sent.review()).isNotEmpty();
|
||||
assertThat(sent.result()).anyMatch(s -> s.contains("SENT"));
|
||||
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
|
||||
var dead = EmailService.send(request());
|
||||
SystemWrites.updateOne(
|
||||
EmailTables.MESSAGE,
|
||||
new QRecord().withValue("id", dead.messageId()).withValue("status", "DEAD_LETTER"));
|
||||
var replay = ProcessRunner.run(EmailProcesses.RESEND, dead.messageId());
|
||||
assertThat(replay.result()).anyMatch(s -> s.contains("QUEUED"));
|
||||
var messages = H2Support.all(EmailTables.MESSAGE);
|
||||
assertThat(messages).hasSize(4);
|
||||
assertThat(messages.getLast().getValueString("linkedTableName")).isEqualTo(EmailTables.MESSAGE);
|
||||
assertThat(messages.getLast().getValueString("htmlBody"))
|
||||
.isEqualTo(messages.get(2).getValueString("htmlBody"));
|
||||
var linked = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(EmailTables.MESSAGE)
|
||||
.withFilter(EmailStore.equal("id", messages.getLast().getValueInteger("id"))).withShouldGenerateDisplayValues(true)).getRecords().getFirst();
|
||||
assertThat(linked.getDisplayValue("linkedRecordId:toRecordFromTableDynamic")).isEqualTo(EmailTables.MESSAGE);
|
||||
var test =
|
||||
ProcessRunner.runWithValues(
|
||||
EmailProcesses.SEND_TEST, Map.of("toAddress", "test@example.com"), 1);
|
||||
assertThat(test.result()).anyMatch(s -> s.contains("SENT"));
|
||||
var skipped = ProcessRunner.run(EmailProcesses.CANCEL, queued.messageId());
|
||||
assertThat(skipped.review()).anyMatch(s -> s.contains("skipped"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run.
|
||||
*******************************************************************************/
|
||||
RunProcessOutput run(String name, Map<String, java.io.Serializable> values) throws Exception
|
||||
{
|
||||
var input = new RunProcessInput();
|
||||
input.setProcessName(name);
|
||||
values.forEach(input::addValue);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
var result = new RunProcessAction().execute(input);
|
||||
if(result.getException().isPresent())
|
||||
{
|
||||
throw result.getException().get();
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend Send And Scheduled Jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void backendSendAndScheduledJobs() throws Exception
|
||||
{
|
||||
var queued =
|
||||
run(
|
||||
EmailProcesses.SEND_TEMPLATED,
|
||||
Map.of(
|
||||
"to",
|
||||
"a@example.com",
|
||||
"subject",
|
||||
"Backend",
|
||||
"markdownBody",
|
||||
"Hello",
|
||||
"category",
|
||||
"NOTIFICATION"));
|
||||
assertThat(queued.getValues().get("status")).isEqualTo("QUEUED");
|
||||
run(EmailProcesses.DISPATCH, Map.of());
|
||||
assertThat(mail.waitForIncomingEmail(2000, 1)).isTrue();
|
||||
run(EmailProcesses.MAINTAIN, Map.of());
|
||||
EmailRuntime.setClock(Clock.fixed(Instant.now().plus(Duration.ofDays(31)), ZoneOffset.UTC));
|
||||
run(EmailProcesses.RETENTION, Map.of());
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("htmlBody")).isNull();
|
||||
var refused = run(EmailProcesses.SEND_TEMPLATED, Map.of("templateName", "later"));
|
||||
assertThat(refused.getValues().get("status")).isEqualTo("REFUSED");
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,13 @@
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scaffold checks for the producer and config.
|
||||
*******************************************************************************/
|
||||
@@ -42,9 +40,10 @@ class EmailQBitProducerTest
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory"));
|
||||
EmailQBitProducer producer = new EmailQBitProducer()
|
||||
.withNamespace(null)
|
||||
.withQBitConfig(new EmailQBitConfig().withBackendName("memory"));
|
||||
EmailQBitProducer producer =
|
||||
new EmailQBitProducer()
|
||||
.withNamespace(null)
|
||||
.withQBitConfig(new EmailQBitConfig().withBackendName("memory"));
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getNamespace()).isNull();
|
||||
assertThat(producer.getQBitConfig().getBackendName()).isEqualTo("memory");
|
||||
@@ -52,8 +51,6 @@ class EmailQBitProducerTest
|
||||
assertThat(instance.getQBits()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing or unknown backend is refused; no config means disabled.
|
||||
*******************************************************************************/
|
||||
@@ -61,9 +58,19 @@ class EmailQBitProducerTest
|
||||
void refusesInvalidConfig()
|
||||
{
|
||||
assertThat(new EmailQBitProducer().isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> new EmailQBitProducer().withQBitConfig(new EmailQBitConfig()).produce(new QInstance()))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("backendName is required");
|
||||
assertThatThrownBy(() -> new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("nope")).produce(new QInstance()))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("backend not found");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new EmailQBitProducer()
|
||||
.withQBitConfig(new EmailQBitConfig())
|
||||
.produce(new QInstance()))
|
||||
.isInstanceOf(QException.class)
|
||||
.hasMessageContaining("backendName is required");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new EmailQBitProducer()
|
||||
.withQBitConfig(new EmailQBitConfig().withBackendName("nope"))
|
||||
.produce(new QInstance()))
|
||||
.isInstanceOf(QException.class)
|
||||
.hasMessageContaining("backend not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** H2Support.
|
||||
*******************************************************************************/
|
||||
public final class H2Support
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Backend.
|
||||
*******************************************************************************/
|
||||
public static RDBMSBackendMetaData backend()
|
||||
{
|
||||
var backend =
|
||||
new RDBMSBackendMetaData()
|
||||
.withName("rdbms")
|
||||
.withVendor("h2")
|
||||
.withHostName("mem")
|
||||
.withDatabaseName("email_" + UUID.randomUUID())
|
||||
.withUsername("sa");
|
||||
backend.setJdbcUrl(
|
||||
ConnectionManager.getJdbcUrl(backend)
|
||||
+ ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
|
||||
return (backend);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prime.
|
||||
*******************************************************************************/
|
||||
public static void prime(RDBMSBackendMetaData backend) throws Exception
|
||||
{
|
||||
try(Connection connection = new ConnectionManager().getConnection(backend))
|
||||
{
|
||||
for(String sql : Files.readString(Path.of("src/test/resources/email-h2.sql")).split(";"))
|
||||
{
|
||||
if(!sql.isBlank())
|
||||
{
|
||||
connection.createStatement().execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance.
|
||||
*******************************************************************************/
|
||||
public static QInstance instance(RDBMSBackendMetaData backend, Consumer<EmailQBitConfig> tweak)
|
||||
throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(
|
||||
new QAuthenticationMetaData()
|
||||
.withName("anonymous")
|
||||
.withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(backend);
|
||||
EmailQBitConfig config = new EmailQBitConfig().withBackendName("rdbms");
|
||||
tweak.accept(config);
|
||||
new EmailQBitProducer().withQBitConfig(config).produce(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup.
|
||||
*******************************************************************************/
|
||||
public static QInstance setup(Consumer<EmailQBitConfig> tweak) throws Exception
|
||||
{
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
var backend = backend();
|
||||
prime(backend);
|
||||
var instance = instance(backend, tweak);
|
||||
QContext.init(instance, admin());
|
||||
return (instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin.
|
||||
*******************************************************************************/
|
||||
public static QSession admin()
|
||||
{
|
||||
return (new QSession().withPermissions(EmailPermissions.ALL));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider.
|
||||
*******************************************************************************/
|
||||
public static QRecord provider(int port) throws Exception
|
||||
{
|
||||
return (insert(
|
||||
EmailTables.PROVIDER,
|
||||
new QRecord()
|
||||
.withValue("name", "relay")
|
||||
.withValue("label", "Relay")
|
||||
.withValue("kind", "SMTP")
|
||||
.withValue("host", "127.0.0.1")
|
||||
.withValue("port", port)
|
||||
.withValue("security", "NONE")
|
||||
.withValue("fromAddress", "sender@example.com")
|
||||
.withValue("enabled", true)
|
||||
.withValue("isDefault", true)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord record) throws Exception
|
||||
{
|
||||
QRecord result =
|
||||
new InsertAction()
|
||||
.execute(new InsertInput(table).withRecords(List.of(record)))
|
||||
.getRecords()
|
||||
.get(0);
|
||||
if(result.getErrors() != null && !result.getErrors().isEmpty())
|
||||
{
|
||||
throw new IllegalStateException("Insert failed: " + result.getErrors());
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> all(String table) throws Exception
|
||||
{
|
||||
return (SystemWrites.query(table, new QQueryFilter()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.email;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs a streamed-ETL table or record action the way the UI does: the
|
||||
** review screen, then the result.
|
||||
*******************************************************************************/
|
||||
public final class ProcessRunner
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Review and result lines, as shown.
|
||||
*******************************************************************************/
|
||||
public record Lines(List<String> review, List<String> result)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ProcessRunner()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run on some record ids.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Lines run(String process, Serializable... ids) throws Exception
|
||||
{
|
||||
return (runWithValues(process, java.util.Map.of(), ids));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run With Values.
|
||||
*******************************************************************************/
|
||||
public static Lines runWithValues(
|
||||
String process, java.util.Map<String, Serializable> values, Serializable... ids)
|
||||
throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(process);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue(
|
||||
"recordIds", Arrays.stream(ids).map(String::valueOf).collect(Collectors.joining(",")));
|
||||
values.forEach(input::addValue);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
RunProcessOutput review = new RunProcessAction().execute(input);
|
||||
if(process.equals(com.kingsrook.qbits.email.processes.EmailProcesses.SEND_TEST))
|
||||
{
|
||||
RunProcessInput supplied = new RunProcessInput();
|
||||
supplied.setProcessName(process);
|
||||
supplied.setProcessUUID(review.getProcessUUID());
|
||||
supplied.setStartAfterStep("input");
|
||||
supplied.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
values.forEach(supplied::addValue);
|
||||
review = new RunProcessAction().execute(supplied);
|
||||
}
|
||||
if(review.getException().isPresent())
|
||||
{
|
||||
throw (review.getException().get());
|
||||
}
|
||||
List<ProcessSummaryLineInterface> reviewLines =
|
||||
(List<ProcessSummaryLineInterface>)
|
||||
review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
|
||||
RunProcessInput go = new RunProcessInput();
|
||||
go.setProcessName(process);
|
||||
go.setProcessUUID(review.getProcessUUID());
|
||||
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
|
||||
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput result = new RunProcessAction().execute(go);
|
||||
if(result.getException().isPresent())
|
||||
{
|
||||
throw (result.getException().get());
|
||||
}
|
||||
List<ProcessSummaryLineInterface> resultLines =
|
||||
(List<ProcessSummaryLineInterface>)
|
||||
result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
return (new Lines(text(reviewLines, false), text(resultLines, true)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Lines as text.
|
||||
*******************************************************************************/
|
||||
private static List<String> text(List<ProcessSummaryLineInterface> lines, boolean past)
|
||||
{
|
||||
return (lines == null
|
||||
? List.of()
|
||||
: lines.stream()
|
||||
.map(
|
||||
line ->
|
||||
{
|
||||
if(line instanceof ProcessSummaryLine summary)
|
||||
{
|
||||
summary.pickMessage(past);
|
||||
return ((summary.getCount() == null ? "" : summary.getCount() + " ")
|
||||
+ summary.getMessage());
|
||||
}
|
||||
return (String.valueOf(line));
|
||||
})
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpHeaders;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider contracts, safe failures and database-to-HTTP delivery without vendors.
|
||||
*******************************************************************************/
|
||||
class EmailHttpTransportsTest
|
||||
{
|
||||
private static final String SECRET = "fake-sensitive-token";
|
||||
private static final String ECHO = "private-recipient@example.com " + SECRET;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deterministic response without any network or third-party test dependency.
|
||||
*******************************************************************************/
|
||||
private record Response(int statusCode, HttpHeaders headers, String body) implements HttpResponse<String>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Unused request metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HttpRequest request()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Redirects are never followed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<HttpResponse<String>> previousResponse()
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No TLS session is needed for the fake.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<SSLSession> sslSession()
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No response URL is consumed by a transport.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public URI uri()
|
||||
{
|
||||
return (URI.create("https://example.com"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test protocol version.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HttpClient.Version version()
|
||||
{
|
||||
return (HttpClient.Version.HTTP_1_1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build an HTTP response with case-insensitive headers.
|
||||
*******************************************************************************/
|
||||
private static Response response(int status, String body, Map<String, List<String>> headers)
|
||||
{
|
||||
return (new Response(status, HttpHeaders.of(headers, (name, value) -> true), body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same envelope exercises alternatives, display names and blind recipients.
|
||||
*******************************************************************************/
|
||||
private static EmailEnvelope envelope()
|
||||
{
|
||||
return (new EmailEnvelope().withMessageId("42").withFrom("Sender <sender@example.com>")
|
||||
.withTo(List.of("Person <to@example.com>")).withCc(List.of("cc@example.com")).withBcc(List.of("bcc@example.com"))
|
||||
.withReplyTo("Replies <reply@example.com>").withSubject("Hello \u00e9").withTextBody("Plain \u00e9").withHtmlBody("<b>Hello \u00e9</b>")
|
||||
.withHeaders(Map.of("List-Unsubscribe", "<https://example.com/unsubscribe>",
|
||||
"List-Unsubscribe-Post", "List-Unsubscribe=One-Click", "X-Custom", "kept")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required row fields, using the new default allowlisted credential names.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String kind)
|
||||
{
|
||||
String env = switch(kind)
|
||||
{
|
||||
case "POSTMARK" -> "POSTMARK_SERVER_TOKEN";
|
||||
case "SENDGRID" -> "SENDGRID_API_KEY";
|
||||
case "MAILGUN" -> "MAILGUN_API_KEY";
|
||||
default -> "GMAIL_APP_PASSWORD";
|
||||
};
|
||||
return (new QRecord().withValue("kind", kind).withValue("secretEnvName", env)
|
||||
.withValue("fromAddress", "sender@example.com").withValue("sendingDomain", "example.com").withValue("region", "US"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secrets are supplied only when the transport starts its attempt.
|
||||
*******************************************************************************/
|
||||
private static EmailProviderSettings settings(QRecord row)
|
||||
{
|
||||
return (new EmailProviderSettings(row, new EmailQBitConfig().withSecretResolver(name -> SECRET)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select the real transport behind the fake exchange seam.
|
||||
*******************************************************************************/
|
||||
private static EmailTransport transport(String kind, EmailHttp http)
|
||||
{
|
||||
return (switch(kind)
|
||||
{
|
||||
case "POSTMARK" -> new PostmarkEmailTransport(http);
|
||||
case "SENDGRID" -> new SendGridEmailTransport(http);
|
||||
case "MAILGUN" -> new MailgunEmailTransport(http);
|
||||
default -> throw new IllegalArgumentException(kind);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read an in-memory request publisher just as a client would.
|
||||
*******************************************************************************/
|
||||
private static byte[] body(HttpRequest request) throws Exception
|
||||
{
|
||||
var subscriber = HttpResponse.BodySubscribers.ofByteArray();
|
||||
request.bodyPublisher().orElseThrow().subscribe(new java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer>()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Pass demand through to the in-memory publisher.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onSubscribe(java.util.concurrent.Flow.Subscription subscription)
|
||||
{
|
||||
subscriber.onSubscribe(subscription);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Request publishers emit buffers; response subscribers consume buffer lists.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onNext(java.nio.ByteBuffer buffer)
|
||||
{
|
||||
subscriber.onNext(List.of(buffer));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve test failures through the completion stage.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onError(Throwable error)
|
||||
{
|
||||
subscriber.onError(error);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete the byte array.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onComplete()
|
||||
{
|
||||
subscriber.onComplete();
|
||||
}
|
||||
});
|
||||
return (subscriber.getBody().toCompletableFuture().get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Postmark includes all rendered fields, opt-in tracking and the chosen stream.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void postmarkRequestAndSuccess(boolean tracking) throws Exception
|
||||
{
|
||||
AtomicReference<HttpRequest> captured = new AtomicReference<>();
|
||||
var transport = new PostmarkEmailTransport(request ->
|
||||
{
|
||||
captured.set(request);
|
||||
return (response(200, "{\"ErrorCode\":0,\"MessageID\":\"pm-id\",\"Message\":\"" + ECHO + "\"}", Map.of()));
|
||||
});
|
||||
var provider = row("POSTMARK").withValue("trackOpens", tracking).withValue("trackClicks", tracking)
|
||||
.withValue("messageStream", tracking ? "broadcasts" : null);
|
||||
assertThat(transport.send(envelope(), settings(provider)).providerMessageId()).isEqualTo("pm-id");
|
||||
HttpRequest request = captured.get();
|
||||
assertThat(request.uri().toString()).isEqualTo("https://api.postmarkapp.com/email");
|
||||
assertThat(request.method()).isEqualTo("POST");
|
||||
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
|
||||
assertThat(request.headers().firstValue("X-Postmark-Server-Token")).contains(SECRET);
|
||||
assertThat(request.headers().firstValue("Content-Type")).contains("application/json");
|
||||
JsonNode json = EmailHttpSupport.JSON.readTree(body(request));
|
||||
assertThat(json.path("From").asText()).isEqualTo(envelope().getFrom());
|
||||
assertThat(json.path("To").asText()).isEqualTo("Person <to@example.com>");
|
||||
assertThat(json.path("Cc").asText()).isEqualTo("cc@example.com");
|
||||
assertThat(json.path("Bcc").asText()).isEqualTo("bcc@example.com");
|
||||
assertThat(json.path("ReplyTo").asText()).isEqualTo(envelope().getReplyTo());
|
||||
assertThat(json.path("Subject").asText()).isEqualTo(envelope().getSubject());
|
||||
assertThat(json.path("TextBody").asText()).isEqualTo(envelope().getTextBody());
|
||||
assertThat(json.path("HtmlBody").asText()).isEqualTo(envelope().getHtmlBody());
|
||||
assertThat(json.path("MessageStream").asText()).isEqualTo(tracking ? "broadcasts" : "outbound");
|
||||
assertThat(json.path("TrackOpens").asBoolean()).isEqualTo(tracking);
|
||||
assertThat(json.path("TrackLinks").asText()).isEqualTo(tracking ? "HtmlAndText" : "None");
|
||||
assertThat(json.path("Headers").toString()).contains("List-Unsubscribe", "List-Unsubscribe-Post", "X-QQQ-Email-Id", "42", "Auto-Submitted");
|
||||
assertThat(json.toString()).doesNotContain(SECRET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SendGrid filters forbidden headers case-insensitively, retaining unsubscribe.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void sendGridRequestAndSuccess(boolean tracking) throws Exception
|
||||
{
|
||||
AtomicReference<HttpRequest> captured = new AtomicReference<>();
|
||||
var transport = new SendGridEmailTransport(request ->
|
||||
{
|
||||
captured.set(request);
|
||||
return (response(202, "", Map.of("x-message-id", List.of("sg-id"))));
|
||||
});
|
||||
Map<String, String> headers = new java.util.HashMap<>(envelope().getHeaders());
|
||||
for(String reserved : List.of("X-SG-ID", "x-sg-eid", "Received", "DKIM-Signature", "Content-Type",
|
||||
"Content-Transfer-Encoding", "To", "From", "Subject", "Reply-To", "CC", "BcC"))
|
||||
{
|
||||
headers.put(reserved, "must-be-removed");
|
||||
}
|
||||
assertThat(transport.send(envelope().withHeaders(headers), settings(row("SENDGRID")
|
||||
.withValue("trackOpens", tracking).withValue("trackClicks", tracking))).providerMessageId()).isEqualTo("sg-id");
|
||||
HttpRequest request = captured.get();
|
||||
assertThat(request.uri().toString()).isEqualTo("https://api.sendgrid.com/v3/mail/send");
|
||||
assertThat(request.method()).isEqualTo("POST");
|
||||
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
|
||||
assertThat(request.headers().firstValue("Authorization")).contains("Bearer " + SECRET);
|
||||
JsonNode json = EmailHttpSupport.JSON.readTree(body(request));
|
||||
assertThat(json.at("/personalizations/0/to/0/email").asText()).isEqualTo("to@example.com");
|
||||
assertThat(json.at("/personalizations/0/to/0/name").asText()).isEqualTo("Person");
|
||||
assertThat(json.at("/personalizations/0/cc/0/email").asText()).isEqualTo("cc@example.com");
|
||||
assertThat(json.at("/personalizations/0/bcc/0/email").asText()).isEqualTo("bcc@example.com");
|
||||
assertThat(json.at("/from/email").asText()).isEqualTo("sender@example.com");
|
||||
assertThat(json.at("/from/name").asText()).isEqualTo("Sender");
|
||||
assertThat(json.at("/reply_to/email").asText()).isEqualTo("reply@example.com");
|
||||
assertThat(json.path("subject").asText()).isEqualTo(envelope().getSubject());
|
||||
assertThat(json.at("/content/0/type").asText()).isEqualTo("text/plain");
|
||||
assertThat(json.at("/content/0/value").asText()).isEqualTo(envelope().getTextBody());
|
||||
assertThat(json.at("/content/1/type").asText()).isEqualTo("text/html");
|
||||
assertThat(json.at("/content/1/value").asText()).isEqualTo(envelope().getHtmlBody());
|
||||
assertThat(json.at("/custom_args/qqq_email_id").asText()).isEqualTo("42");
|
||||
assertThat(json.path("headers").toString()).doesNotContain("must-be-removed").contains("List-Unsubscribe", "List-Unsubscribe-Post");
|
||||
assertThat(json.at("/tracking_settings/open_tracking/enable").asBoolean()).isEqualTo(tracking);
|
||||
assertThat(json.at("/tracking_settings/click_tracking/enable").asBoolean()).isEqualTo(tracking);
|
||||
assertThat(json.at("/tracking_settings/click_tracking/enable_text").asBoolean()).isEqualTo(tracking);
|
||||
assertThat(json.toString()).doesNotContain(SECRET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Multipart preserves the MIME bytes and explicit Bcc envelope destinations.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({"US,false", "EU,true"})
|
||||
void mailgunRequestAndSuccess(String region, boolean tracking) throws Exception
|
||||
{
|
||||
AtomicReference<HttpRequest> captured = new AtomicReference<>();
|
||||
var transport = new MailgunEmailTransport(request ->
|
||||
{
|
||||
captured.set(request);
|
||||
return (response(200, "{\"id\":\"<mg-id@example.com>\",\"message\":\"" + ECHO + "\"}", Map.of()));
|
||||
});
|
||||
assertThat(transport.send(envelope(), settings(row("MAILGUN").withValue("region", region)
|
||||
.withValue("trackOpens", tracking).withValue("trackClicks", tracking))).providerMessageId()).isEqualTo("mg-id@example.com");
|
||||
HttpRequest request = captured.get();
|
||||
assertThat(request.uri().toString()).isEqualTo("https://api." + ("EU".equals(region) ? "eu." : "") + "mailgun.net/v3/example.com/messages.mime");
|
||||
assertThat(request.method()).isEqualTo("POST");
|
||||
assertThat(request.timeout()).contains(Duration.ofSeconds(30));
|
||||
assertThat(request.headers().firstValue("Authorization")).contains("Basic " + Base64.getEncoder().encodeToString(("api:" + SECRET).getBytes(StandardCharsets.UTF_8)));
|
||||
String contentType = request.headers().firstValue("Content-Type").orElseThrow();
|
||||
String boundary = contentType.substring(contentType.indexOf("boundary=") + 9);
|
||||
String multipart = new String(body(request), StandardCharsets.UTF_8);
|
||||
assertThat(multipart).startsWith("--" + boundary + "\r\n").endsWith("\r\n--" + boundary + "--\r\n");
|
||||
assertThat(multipart).contains("name=\"to\"\r\n\r\nto@example.com,cc@example.com,bcc@example.com\r\n");
|
||||
assertThat(multipart).contains("name=\"o:tracking-opens\"\r\n\r\n" + (tracking ? "yes" : "no") + "\r\n");
|
||||
assertThat(multipart).contains("name=\"o:tracking-clicks\"\r\n\r\n" + (tracking ? "yes" : "no") + "\r\n");
|
||||
assertThat(multipart).contains("name=\"v:qqq_email_id\"\r\n\r\n42\r\n", "name=\"message\"; filename=\"message.eml\"");
|
||||
String mime = multipart.substring(multipart.indexOf("Content-Type: message/rfc822\r\n\r\n") + "Content-Type: message/rfc822\r\n\r\n".length());
|
||||
mime = mime.substring(0, mime.lastIndexOf("\r\n--" + boundary));
|
||||
assertThat(mime).doesNotContain("bcc@example.com", "Bcc:", SECRET);
|
||||
MimeMessage message = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(mime.getBytes(StandardCharsets.UTF_8)));
|
||||
assertThat(message.getSubject()).isEqualTo(envelope().getSubject());
|
||||
assertThat(message.getHeader("X-QQQ-Email-Id", null)).isEqualTo("42");
|
||||
assertThat(message.getHeader("List-Unsubscribe-Post", null)).isEqualTo("List-Unsubscribe=One-Click");
|
||||
assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.CC)[0].toString()).isEqualTo("cc@example.com");
|
||||
var alternatives = (jakarta.mail.Multipart) message.getContent();
|
||||
assertThat(alternatives.getBodyPart(0).getContent().toString()).isEqualTo(envelope().getTextBody());
|
||||
assertThat(alternatives.getBodyPart(1).getContent().toString()).isEqualTo(envelope().getHtmlBody());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider codes are numeric and never include echoed diagnostics.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"10,false,true", "300,true,false", "406,true,false", "400,false,true", "401,false,true",
|
||||
"405,false,true", "412,false,true", "413,false,true", "422,false,true",
|
||||
"1235,false,true", "1236,false,true", "1480,false,true", "403,true,false"
|
||||
})
|
||||
void postmarkErrorCodes(int code, boolean permanent, boolean providerLevel)
|
||||
{
|
||||
assertFailure(new PostmarkEmailTransport(request -> response(422,
|
||||
"{\"ErrorCode\":" + code + ",\"Message\":\"" + ECHO + "\"}", Map.of())), "POSTMARK", permanent, providerLevel, null, "code " + code);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP status classification takes precedence over Postmark's JSON error code.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"POSTMARK,401,false,true", "POSTMARK,403,false,true", "POSTMARK,400,true,false", "POSTMARK,413,true,false",
|
||||
"POSTMARK,429,false,false", "POSTMARK,500,false,false", "POSTMARK,503,false,false", "POSTMARK,302,false,true",
|
||||
"SENDGRID,401,false,true", "SENDGRID,403,false,true", "SENDGRID,400,true,false", "SENDGRID,413,true,false",
|
||||
"SENDGRID,429,false,false", "SENDGRID,500,false,false", "SENDGRID,503,false,false", "SENDGRID,302,false,true",
|
||||
"MAILGUN,401,false,true", "MAILGUN,403,false,true", "MAILGUN,400,true,false", "MAILGUN,413,true,false",
|
||||
"MAILGUN,429,false,false", "MAILGUN,500,false,false", "MAILGUN,503,false,false", "MAILGUN,302,false,true"
|
||||
})
|
||||
void httpFailures(String kind, int status, boolean permanent, boolean providerLevel)
|
||||
{
|
||||
String body = status >= 429 ? "{\"ErrorCode\":10,\"Message\":\"" + ECHO + "\"}" : ECHO;
|
||||
assertFailure(transport(kind, request -> response(status, body, Map.of("Retry-After", List.of("123")))),
|
||||
kind, permanent, providerLevel, Duration.ofSeconds(123), "HTTP " + status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert flags and the entire exposed exception remain free of diagnostics.
|
||||
*******************************************************************************/
|
||||
private static void assertFailure(EmailTransport transport, String kind, boolean permanent, boolean providerLevel, Duration retryAfter, String message)
|
||||
{
|
||||
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind))))
|
||||
.isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getPermanent()).isEqualTo(permanent);
|
||||
assertThat(error.getProviderLevel()).isEqualTo(providerLevel);
|
||||
assertThat(error.getRetryAfter()).isEqualTo(retryAfter);
|
||||
assertThat(error).hasMessageContaining(message).hasNoCause();
|
||||
assertThat(error.toString()).doesNotContain(SECRET, ECHO, "private-recipient");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Support delta seconds, HTTP dates, stale dates and invalid retry headers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retryAfterAndHttpPolicy() throws Exception
|
||||
{
|
||||
String future = ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(5).format(DateTimeFormatter.RFC_1123_DATE_TIME);
|
||||
Duration delay = EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of(future))));
|
||||
assertThat(delay.getSeconds()).isBetween(295L, 300L);
|
||||
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of("0"))))).isEqualTo(Duration.ZERO);
|
||||
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of("Sun, 06 Nov 1994 08:49:37 GMT"))))).isEqualTo(Duration.ZERO);
|
||||
for(String value : List.of("-1", "99999999999999999999999", "invalid", ""))
|
||||
{
|
||||
assertThat(EmailHttpSupport.retryAfter(response(429, "", Map.of("retry-after", List.of(value))))).isNull();
|
||||
}
|
||||
try(HttpClient client = EmailHttp.newClient())
|
||||
{
|
||||
assertThat(client.followRedirects()).isEqualTo(HttpClient.Redirect.NEVER);
|
||||
assertThat(client.connectTimeout()).contains(Duration.ofSeconds(10));
|
||||
}
|
||||
assertThatThrownBy(() -> EmailHttp.sendJdk(HttpRequest.newBuilder(URI.create("http://127.0.0.1/")).build()))
|
||||
.isInstanceOf(IOException.class).hasMessage("Email HTTP requires HTTPS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown or malformed successes must not be silently recorded as sent.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"POSTMARK", "SENDGRID", "MAILGUN"})
|
||||
void invalidResponsesAndNetworkFailures(String kind) throws Exception
|
||||
{
|
||||
int success = "SENDGRID".equals(kind) ? 202 : 200;
|
||||
for(String body : List.of(ECHO, "", "[]", "null", "{}", "{\"ErrorCode\":0}", "{\"id\":123}", "{\"ErrorCode\":\"" + SECRET + "\"}"))
|
||||
{
|
||||
assertFailure(transport(kind, request -> response(success, body, Map.of())), kind, false, false, null, "provider");
|
||||
}
|
||||
assertFailure(transport(kind, request ->
|
||||
{
|
||||
throw new IOException(ECHO);
|
||||
}), kind, false, false, null, "temporarily unavailable");
|
||||
assertFailure(transport(kind, request ->
|
||||
{
|
||||
throw new IllegalArgumentException(ECHO);
|
||||
}), kind, false, true, null, "configuration invalid");
|
||||
try
|
||||
{
|
||||
assertFailure(transport(kind, request ->
|
||||
{
|
||||
throw new InterruptedException(ECHO);
|
||||
}), kind, false, false, null, "interrupted");
|
||||
assertThat(Thread.currentThread().isInterrupted()).isTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required secrets resolve per attempt, never on construction or failed validation.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"POSTMARK", "SENDGRID", "MAILGUN"})
|
||||
void secretResolutionAndInvalidEnvelopes(String kind) throws Exception
|
||||
{
|
||||
EmailHttp mustNotSend = request ->
|
||||
{
|
||||
throw new AssertionError("No HTTP request should have been sent");
|
||||
};
|
||||
var transport = transport(kind, mustNotSend);
|
||||
var config = new EmailQBitConfig().withSecretResolver(name -> null);
|
||||
assertThatThrownBy(() -> transport.send(envelope(), new EmailProviderSettings(row(kind), config)))
|
||||
.hasNoCause().hasMessage("Credential environment variable is not set");
|
||||
config.withSecretResolver(name ->
|
||||
{
|
||||
throw new IllegalStateException(ECHO);
|
||||
});
|
||||
assertThatThrownBy(() -> transport.send(envelope(), new EmailProviderSettings(row(kind), config)))
|
||||
.hasNoCause().hasMessage("Credential environment variable is not set");
|
||||
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind).withValue("secretEnvName", null))))
|
||||
.hasNoCause().hasMessage("Provider needs a secret environment name");
|
||||
assertThatThrownBy(() -> transport.send(envelope(), settings(row(kind).withValue("secretEnvName", "UNAPPROVED"))))
|
||||
.hasNoCause().hasMessage("Credential environment name is not allowed");
|
||||
for(EmailEnvelope invalid : List.of(envelope().withSubject("Injected\r\nBcc: " + ECHO),
|
||||
envelope().withTo(List.of("bad\r\naddress")), envelope().withHeaders(Map.of("X-Test", "bad\n" + ECHO))))
|
||||
{
|
||||
assertThatThrownBy(() -> transport.send(invalid, settings(row(kind))))
|
||||
.hasNoCause().hasMessage("Invalid email message");
|
||||
}
|
||||
|
||||
AtomicInteger resolutions = new AtomicInteger();
|
||||
AtomicInteger sends = new AtomicInteger();
|
||||
var rotating = new EmailProviderSettings(row(kind), new EmailQBitConfig().withSecretResolver(name -> "token-" + resolutions.incrementAndGet()));
|
||||
var real = transport(kind, request ->
|
||||
{
|
||||
String token = "token-" + sends.incrementAndGet();
|
||||
String auth = "POSTMARK".equals(kind) ? request.headers().firstValue("X-Postmark-Server-Token").orElseThrow()
|
||||
: request.headers().firstValue("Authorization").orElseThrow();
|
||||
String expected = "MAILGUN".equals(kind) ? "Basic " + Base64.getEncoder().encodeToString(("api:" + token).getBytes(StandardCharsets.UTF_8))
|
||||
: "SENDGRID".equals(kind) ? "Bearer " + token : token;
|
||||
assertThat(auth).isEqualTo(expected);
|
||||
return (response("SENDGRID".equals(kind) ? 202 : 200,
|
||||
"{\"ErrorCode\":0,\"MessageID\":\"id\",\"id\":\"id\"}", Map.of("X-Message-Id", List.of("id"))));
|
||||
});
|
||||
assertThat(resolutions.get()).isZero();
|
||||
real.send(envelope(), rotating);
|
||||
real.send(envelope(), rotating);
|
||||
assertThat(resolutions.get()).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional fields are omitted, null tracking is disabled and streams default.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"POSTMARK", "SENDGRID"})
|
||||
void minimalJsonMessages(String kind) throws Exception
|
||||
{
|
||||
AtomicReference<HttpRequest> captured = new AtomicReference<>();
|
||||
var transport = transport(kind, request ->
|
||||
{
|
||||
captured.set(request);
|
||||
return (response("SENDGRID".equals(kind) ? 202 : 200, "{\"ErrorCode\":0,\"MessageID\":\"id\"}",
|
||||
Map.of("X-Message-Id", List.of("id"))));
|
||||
});
|
||||
for(boolean htmlOnly : List.of(true, false))
|
||||
{
|
||||
var minimal = envelope().withFrom("sender@example.com").withCc(List.of()).withBcc(List.of()).withReplyTo(null)
|
||||
.withTextBody(htmlOnly ? null : "plain").withHtmlBody(htmlOnly ? "<b>html</b>" : null);
|
||||
transport.send(minimal, settings(row(kind).withValue("messageStream", "")));
|
||||
JsonNode json = EmailHttpSupport.JSON.readTree(body(captured.get()));
|
||||
if("SENDGRID".equals(kind))
|
||||
{
|
||||
assertThat(json.at("/content").size()).isEqualTo(1);
|
||||
assertThat(json.at("/personalizations/0").has("cc")).isFalse();
|
||||
assertThat(json.at("/personalizations/0").has("bcc")).isFalse();
|
||||
assertThat(json.has("reply_to")).isFalse();
|
||||
assertThat(json.at("/from").has("name")).isFalse();
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(json.has("ReplyTo")).isFalse();
|
||||
assertThat(json.has(htmlOnly ? "TextBody" : "HtmlBody")).isFalse();
|
||||
assertThat(json.path("MessageStream").asText()).isEqualTo("outbound");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication and regional requirements are validated without resolving values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerValidationAndGmailPreset() throws Exception
|
||||
{
|
||||
var config = new EmailQBitConfig().withSecretResolver(name ->
|
||||
{
|
||||
throw new AssertionError("Validation must not resolve a secret");
|
||||
});
|
||||
for(String kind : List.of("POSTMARK", "SENDGRID", "MAILGUN"))
|
||||
{
|
||||
config.withAllowedEnvNames(Set.of());
|
||||
QRecord provider = row(kind);
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider, config)).isEmpty();
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider.withValue("secretEnvName", null), config))
|
||||
.contains("Provider needs a secret environment name");
|
||||
provider.withValue("secretEnvName", "CUSTOM_TOKEN");
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider, config)).isNotEmpty();
|
||||
assertThat(EmailTableCustomizers.validateProvider(provider, config.withAllowedEnvNames(Set.of("CUSTOM_TOKEN")))).isEmpty();
|
||||
}
|
||||
for(String domain : List.of("", "https://example.com", "../example.com", "example.com/path", "example.com?x=y", "-bad.com", "bad-.com"))
|
||||
{
|
||||
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("sendingDomain", domain), config)).isNotEmpty();
|
||||
assertThatThrownBy(() -> new MailgunEmailTransport(request ->
|
||||
{
|
||||
throw new AssertionError("Invalid domain must not send");
|
||||
}).send(envelope(), settings(row("MAILGUN").withValue("sendingDomain", domain))))
|
||||
.isInstanceOfSatisfying(EmailTransportException.class, error -> assertThat(error.getProviderLevel()).isTrue());
|
||||
}
|
||||
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", "EU"), config)).isEmpty();
|
||||
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", null), config)).contains("Mailgun region must be US or EU");
|
||||
assertThat(EmailTableCustomizers.validateProvider(row("MAILGUN").withValue("region", "APAC"), config)).contains("Mailgun region must be US or EU");
|
||||
Map<String, String> webhookNames = Map.of("POSTMARK", "POSTMARK_WEBHOOK_PASSWORD", "SENDGRID", "SENDGRID_WEBHOOK_PUBLIC_KEY", "MAILGUN", "MAILGUN_WEBHOOK_SIGNING_KEY");
|
||||
webhookNames.forEach((kind, env) -> assertThat(EmailTableCustomizers.validateProvider(row(kind).withValue("webhookSecretEnvName", env), config)).isEmpty());
|
||||
assertThat(config.effectiveAllowedEnvNames("SES")).doesNotContain("SES_SNS_TOPIC_ARN");
|
||||
|
||||
QRecord gmail = row("GMAIL_SMTP").withValue("usernameEnvName", "GMAIL_USERNAME");
|
||||
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).isEmpty();
|
||||
var settings = new EmailProviderSettings(gmail.withValue("host", "ignored.example.com").withValue("port", 25), config);
|
||||
assertThat(settings.host()).isEqualTo("smtp.gmail.com");
|
||||
assertThat(settings.port()).isEqualTo(587);
|
||||
assertThat(settings.security()).isEqualTo("STARTTLS");
|
||||
assertThat(new EmailTransportRegistry().get("GMAIL_SMTP")).isInstanceOf(SmtpEmailTransport.class);
|
||||
assertThat(EmailTableCustomizers.validateProvider(gmail.withValue("security", "STARTTLS"), config)).isEmpty();
|
||||
for(String security : List.of("NONE", "TLS", "invalid"))
|
||||
{
|
||||
assertThat(EmailTableCustomizers.validateProvider(gmail.withValue("security", security), config.withAllowPlaintextSmtpAuth(true)))
|
||||
.contains("Gmail SMTP requires STARTTLS");
|
||||
assertThatThrownBy(() -> new SmtpEmailTransport().send(envelope(), settings))
|
||||
.isInstanceOfSatisfying(EmailTransportException.class, error -> assertThat(error.getProviderLevel()).isTrue());
|
||||
}
|
||||
gmail.withValue("security", "STARTTLS").withValue("usernameEnvName", null);
|
||||
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).contains("Gmail SMTP needs an account address environment name");
|
||||
assertThatThrownBy(() -> new SmtpEmailTransport().send(envelope(), settings)).hasMessageContaining("both credential names");
|
||||
gmail.withValue("usernameEnvName", "GMAIL_USERNAME").withValue("secretEnvName", null);
|
||||
assertThat(EmailTableCustomizers.validateProvider(gmail, config)).contains("Provider needs a secret environment name");
|
||||
assertThat(EmailTableCustomizers.validateProvider(row("GMAIL_API"), config))
|
||||
.contains("Gmail API sending is specified but not built; use GMAIL_SMTP");
|
||||
assertThatThrownBy(() -> new EmailTransportRegistry().get("GMAIL_API"))
|
||||
.hasMessage("Gmail API sending is specified but not built; use GMAIL_SMTP");
|
||||
assertThatThrownBy(() -> new EmailTransportRegistry().get("future")).hasMessage("Unknown email provider kind");
|
||||
assertThat(new EmailTransportRegistry().get("POSTMARK")).isInstanceOf(PostmarkEmailTransport.class);
|
||||
assertThat(new EmailTransportRegistry().get("SENDGRID")).isInstanceOf(SendGridEmailTransport.class);
|
||||
assertThat(new EmailTransportRegistry().get("MAILGUN")).isInstanceOf(MailgunEmailTransport.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A real socket exchange from enqueue through dispatcher, including stored errors.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"POSTMARK,200,SENT", "POSTMARK,401,RETRY_WAIT", "POSTMARK,400,DEAD_LETTER", "POSTMARK,429,RETRY_WAIT",
|
||||
"SENDGRID,202,SENT", "SENDGRID,403,RETRY_WAIT", "SENDGRID,413,DEAD_LETTER", "SENDGRID,503,RETRY_WAIT",
|
||||
"MAILGUN,200,SENT", "MAILGUN,401,RETRY_WAIT", "MAILGUN,400,DEAD_LETTER", "MAILGUN,429,RETRY_WAIT"
|
||||
})
|
||||
void dispatchThroughLocalHttp(String kind, int status, String expectedStatus) throws Exception
|
||||
{
|
||||
Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
AtomicInteger resolutions = new AtomicInteger();
|
||||
AtomicReference<String> received = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange ->
|
||||
{
|
||||
received.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
exchange.getResponseHeaders().set("X-Message-Id", "http-id");
|
||||
exchange.getResponseHeaders().set("Retry-After", "123");
|
||||
byte[] reply = ("{\"ErrorCode\":" + (status == 200 ? 0 : 300)
|
||||
+ ",\"MessageID\":\"http-id\",\"id\":\"<http-id>\",\"Message\":\"" + ECHO + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, reply.length);
|
||||
exchange.getResponseBody().write(reply);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try(HttpClient client = EmailHttp.newClient())
|
||||
{
|
||||
EmailRuntime.setClock(Clock.fixed(now, ZoneOffset.UTC));
|
||||
H2Support.setup(config -> config.withSecretResolver(name ->
|
||||
{
|
||||
resolutions.incrementAndGet();
|
||||
return (SECRET);
|
||||
}));
|
||||
H2Support.insert(EmailTables.PROVIDER, row(kind).withValue("name", "http").withValue("label", "HTTP")
|
||||
.withValue("enabled", true).withValue("isDefault", true));
|
||||
var queued = EmailService.send(new EmailRequest().withTo("to@example.com").withCc("cc@example.com").withBcc("bcc@example.com")
|
||||
.withSubject("HTTP integration").withTextBody("body"));
|
||||
assertThat(queued.status()).isEqualTo(EmailSendResult.Status.QUEUED);
|
||||
assertThat(resolutions.get()).isZero();
|
||||
assertThat(received.get()).isNull();
|
||||
EmailHttp local = request ->
|
||||
{
|
||||
// Only the test seam rewrites the fixed production URL to a local server.
|
||||
HttpRequest.Builder localRequest = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + request.uri().getPath()))
|
||||
.timeout(request.timeout().orElseThrow()).POST(request.bodyPublisher().orElseThrow());
|
||||
request.headers().map().forEach((name, values) -> values.forEach(value -> localRequest.header(name, value)));
|
||||
return (client.send(localRequest.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
};
|
||||
var dispatcher = new EmailDispatcher(new EmailTransportRegistry().withTransport(kind, transport(kind, local)));
|
||||
dispatcher.runOnce();
|
||||
assertThat(resolutions.get()).isEqualTo(1);
|
||||
assertThat(received.get()).contains("to@example.com", "bcc@example.com").doesNotContain(SECRET);
|
||||
QRecord message = EmailStore.get(EmailTables.MESSAGE, queued.messageId(), null);
|
||||
QRecord attempt = H2Support.all(EmailTables.ATTEMPT).getFirst();
|
||||
QRecord provider = H2Support.all(EmailTables.PROVIDER).getFirst();
|
||||
assertThat(message.getValueString("status")).isEqualTo(expectedStatus);
|
||||
if("SENT".equals(expectedStatus))
|
||||
{
|
||||
assertThat(message.getValueString("providerMessageId")).isEqualTo("http-id");
|
||||
assertThat(attempt.getValueString("outcome")).isEqualTo("SUCCEEDED");
|
||||
assertThat(message.getValueString("lastError")).isNull();
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(message.getValueString("lastError")).doesNotContain(SECRET, "private-recipient", ECHO);
|
||||
assertThat(attempt.getValueString("error")).isEqualTo(message.getValueString("lastError"));
|
||||
assertThat(provider.getValueString("lastFailureMessage")).isEqualTo(message.getValueString("lastError"));
|
||||
if(status == 401 || status == 403)
|
||||
{
|
||||
assertThat(provider.getValueString("healthStatus")).isEqualTo("UNHEALTHY");
|
||||
assertThat(attempt.getValueString("outcome")).isEqualTo("PROVIDER_FAILURE");
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(attempt.getValueString("outcome")).isEqualTo("DEAD_LETTER".equals(expectedStatus) ? "PERMANENT_FAILURE" : "TRANSIENT_FAILURE");
|
||||
}
|
||||
if("RETRY_WAIT".equals(expectedStatus))
|
||||
{
|
||||
assertThat(message.getValueInstant("nextAttemptDate")).isEqualTo(now.plusSeconds(123));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop(0);
|
||||
QContext.clear();
|
||||
EmailRuntime.setClock(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* 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.transport;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.delivery.EmailDispatcher;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.sun.mail.smtp.SMTPAddressFailedException;
|
||||
import com.sun.mail.smtp.SMTPSendFailedException;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.SendFailedException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import software.amazon.awssdk.services.sesv2.SesV2Client;
|
||||
import software.amazon.awssdk.services.sesv2.model.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Email Transports Test.
|
||||
*******************************************************************************/
|
||||
class EmailTransportsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Envelope.
|
||||
*******************************************************************************/
|
||||
EmailEnvelope envelope()
|
||||
{
|
||||
return (new EmailEnvelope()
|
||||
.withMessageId("123")
|
||||
.withFrom("sender@example.com")
|
||||
.withTo(List.of("a@example.com"))
|
||||
.withBcc(List.of("hidden@example.com"))
|
||||
.withReplyTo("reply@example.com")
|
||||
.withSubject("Subject")
|
||||
.withHtmlBody("<p>HTML</p>")
|
||||
.withTextBody("Text")
|
||||
.withHeaders(Map.of("X-Test", "yes")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Settings.
|
||||
*******************************************************************************/
|
||||
EmailProviderSettings settings()
|
||||
{
|
||||
return (new EmailProviderSettings(
|
||||
new QRecord()
|
||||
.withValue("kind", "SES")
|
||||
.withValue("region", "us-east-1")
|
||||
.withValue("configurationSet", "events")
|
||||
.withValue("usernameEnvName", "AWS_ACCESS_KEY_ID")
|
||||
.withValue("secretEnvName", "AWS_SECRET_ACCESS_KEY"),
|
||||
new EmailQBitConfig().withSecretResolver(n -> "test-only")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Client.
|
||||
*******************************************************************************/
|
||||
SesV2Client client(java.util.function.Function<SendEmailRequest, SendEmailResponse> send)
|
||||
{
|
||||
return ((SesV2Client)
|
||||
Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[] {SesV2Client.class},
|
||||
(proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().equals("sendEmail"))
|
||||
{
|
||||
return (send.apply((SendEmailRequest) args[0]));
|
||||
}
|
||||
if(method.getName().equals("serviceName"))
|
||||
{
|
||||
return ("sesv2");
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ses Success Preserves Mime And Does Not Expose Bcc.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sesSuccessPreservesMimeAndDoesNotExposeBcc() throws Exception
|
||||
{
|
||||
AtomicReference<SendEmailRequest> captured = new AtomicReference<>();
|
||||
var transport =
|
||||
new SesEmailTransport(
|
||||
builder ->
|
||||
client(
|
||||
request ->
|
||||
{
|
||||
captured.set(request);
|
||||
return (SendEmailResponse.builder().messageId("ses-id").build());
|
||||
}));
|
||||
assertThat(transport.send(envelope().withTo(List.of("Pat <a@example.com>, b@example.com")), settings()).providerMessageId()).isEqualTo("ses-id");
|
||||
assertThat(captured.get().destination().toAddresses()).containsExactly("a@example.com", "b@example.com");
|
||||
assertThat(captured.get().configurationSetName()).isEqualTo("events");
|
||||
assertThat(captured.get().destination().bccAddresses()).containsExactly("hidden@example.com");
|
||||
var mime =
|
||||
new MimeMessage(
|
||||
Session.getInstance(new Properties()),
|
||||
new ByteArrayInputStream(captured.get().content().raw().data().asByteArray()));
|
||||
assertThat(mime.getHeader("Bcc")).isNull();
|
||||
assertThat(mime.getHeader("X-Test")).containsExactly("yes");
|
||||
assertThat(mime.getHeader("Auto-Submitted")).containsExactly("auto-generated");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ses Classifies Throttling Rejection And Server Failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sesClassifiesThrottlingRejectionAndServerFailure() throws Exception
|
||||
{
|
||||
for(var error :
|
||||
List.of(
|
||||
TooManyRequestsException.builder().statusCode(429).message("secret").build(),
|
||||
MessageRejectedException.builder().statusCode(400).message("secret").build(),
|
||||
SesV2Exception.builder().statusCode(503).message("secret").build()))
|
||||
{
|
||||
var transport =
|
||||
new SesEmailTransport(
|
||||
builder ->
|
||||
client(
|
||||
request ->
|
||||
{
|
||||
throw error;
|
||||
}));
|
||||
try
|
||||
{
|
||||
transport.send(envelope(), settings());
|
||||
fail("Expected failure");
|
||||
}
|
||||
catch(EmailTransportException actual)
|
||||
{
|
||||
assertThat(actual.getPermanent()).isEqualTo(error.statusCode() == 400);
|
||||
assertThat(actual.getMessage()).doesNotContain("secret");
|
||||
assertThat(actual.getCause()).isNull();
|
||||
}
|
||||
}
|
||||
var defaultChain =
|
||||
new EmailProviderSettings(
|
||||
new QRecord().withValue("kind", "SES").withValue("region", "us-east-1"),
|
||||
new EmailQBitConfig());
|
||||
assertThat(
|
||||
new SesEmailTransport(
|
||||
builder ->
|
||||
client(r -> SendEmailResponse.builder().messageId("default").build()))
|
||||
.send(envelope(), defaultChain)
|
||||
.providerMessageId())
|
||||
.isEqualTo("default");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new SesEmailTransport(
|
||||
b ->
|
||||
{
|
||||
throw new IllegalArgumentException("secret");
|
||||
})
|
||||
.send(envelope(), settings()))
|
||||
.hasMessage("SES temporarily unavailable");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Smtp Classifies Nested Replies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void smtpClassifiesNestedReplies() throws Exception
|
||||
{
|
||||
var permanent =
|
||||
new SMTPAddressFailedException(
|
||||
new InternetAddress("a@example.com"), "RCPT TO", 550, "secret recipient");
|
||||
var transientError =
|
||||
new SMTPAddressFailedException(
|
||||
new InternetAddress("a@example.com"), "RCPT TO", 450, "temporary");
|
||||
var wrapper = new SendFailedException("failed");
|
||||
wrapper.setNextException(permanent);
|
||||
assertThat(SmtpEmailTransport.permanent(wrapper)).isTrue();
|
||||
assertThat(SmtpEmailTransport.permanent(transientError)).isFalse();
|
||||
assertThat(
|
||||
SmtpEmailTransport.permanent(
|
||||
new MessagingException("timeout", new java.net.SocketTimeoutException())))
|
||||
.isFalse();
|
||||
assertThat(SmtpEmailTransport.permanent(new jakarta.mail.AuthenticationFailedException("454 Temporary authentication failure"))).isFalse();
|
||||
assertThat(SmtpEmailTransport.permanent(new jakarta.mail.AuthenticationFailedException()))
|
||||
.isFalse();
|
||||
assertThat(SmtpEmailTransport.providerLevel(new jakarta.mail.AuthenticationFailedException("535 Authentication refused"))).isTrue();
|
||||
assertThat(
|
||||
SmtpEmailTransport.permanent(
|
||||
new SMTPSendFailedException("DATA", 550, "failed", null, null, null, null)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Account and credential errors are provider failures, not message rejections.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sesAccountFailuresAreRetryable() throws Exception
|
||||
{
|
||||
for(String code : List.of("AccountSuspended", "SendingPaused", "MailFromDomainNotVerified", "InvalidClientTokenId", "SignatureDoesNotMatch"))
|
||||
{
|
||||
assertSesFailure(400, code, true, false);
|
||||
}
|
||||
assertSesFailure(401, "Unauthorized", true, false);
|
||||
assertSesFailure(403, "AccessDenied", true, false);
|
||||
assertSesFailure(400, "InvalidParameterValue", false, true);
|
||||
assertSesFailure(400, "MessageRejected", false, true);
|
||||
assertSesFailure(429, "TooManyRequests", false, false);
|
||||
assertSesFailure(503, "ServiceUnavailable", false, false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SES 403 is persisted as an unhealthy provider and recovers without a dead letter.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sesForbiddenPausesProviderAndRecovers() throws Exception
|
||||
{
|
||||
var now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
try
|
||||
{
|
||||
H2Support.setup(config -> config.withMaxAttempts(1));
|
||||
EmailRuntime.setClock(Clock.fixed(now, ZoneOffset.UTC));
|
||||
H2Support.insert(EmailTables.PROVIDER,
|
||||
new QRecord().withValue("kind", "SES").withValue("name", "ses").withValue("region", "us-east-1")
|
||||
.withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
|
||||
var queued = EmailService.send(new EmailRequest()
|
||||
.withTo("a@example.com").withSubject("subject").withTextBody("body"));
|
||||
AtomicReference<Boolean> forbidden = new AtomicReference<>(true);
|
||||
var transport = new SesEmailTransport(builder -> client(request ->
|
||||
{
|
||||
if(forbidden.get())
|
||||
{
|
||||
throw SesV2Exception.builder().statusCode(403).message("private diagnostic").build();
|
||||
}
|
||||
return (SendEmailResponse.builder().messageId("ses-ok").build());
|
||||
}));
|
||||
var dispatcher = new EmailDispatcher(new EmailTransportRegistry().withTransport("SES", transport));
|
||||
assertThat(dispatcher.runOnce().failed()).isEqualTo(1);
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER).getFirst().getValueString("healthStatus"))
|
||||
.isEqualTo("UNHEALTHY");
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE).getFirst().getValueString("status"))
|
||||
.isEqualTo("RETRY_WAIT");
|
||||
forbidden.set(false);
|
||||
EmailRuntime.setClock(Clock.fixed(now.plusSeconds(301), ZoneOffset.UTC));
|
||||
assertThat(dispatcher.sendNow(queued.messageId())).isEqualTo(EmailDispatcher.SendResult.SENT);
|
||||
assertThat(H2Support.all(EmailTables.PROVIDER).getFirst().getValueString("healthStatus"))
|
||||
.isEqualTo("HEALTHY");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
EmailRuntime.setClock(null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise classification through the real SES send path with a fake client.
|
||||
*******************************************************************************/
|
||||
private void assertSesFailure(int status, String code, boolean provider, boolean permanent)
|
||||
{
|
||||
var transport = new SesEmailTransport(builder -> client(request ->
|
||||
{
|
||||
throw SesV2Exception.builder().statusCode(status).message("private diagnostic")
|
||||
.awsErrorDetails(software.amazon.awssdk.awscore.exception.AwsErrorDetails.builder().errorCode(code).build()).build();
|
||||
}));
|
||||
assertThatThrownBy(() -> transport.send(envelope(), settings())).isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getProviderLevel()).isEqualTo(provider);
|
||||
assertThat(error.getPermanent()).isEqualTo(permanent);
|
||||
assertThat(error.getMessage()).doesNotContain("private diagnostic");
|
||||
assertThat(error.getCause()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** MIME construction refuses CR and LF in every address role and the subject.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mimeRejectsHeaderInjectionAndBuildFailuresArePermanent()
|
||||
{
|
||||
for(String newline : List.of("\r", "\n"))
|
||||
{
|
||||
String bad = "a@example.com" + newline + "Bcc: hidden@example.com";
|
||||
for(EmailEnvelope envelope : List.of(envelope().withSubject(bad), envelope().withFrom(bad), envelope().withReplyTo(bad),
|
||||
envelope().withTo(List.of(bad)), envelope().withCc(List.of(bad)), envelope().withBcc(List.of(bad)),
|
||||
envelope().withHeaders(Map.of("X-Test", bad)), envelope().withFrom("broken <")))
|
||||
{
|
||||
assertThatThrownBy(() -> EmailMime.build(Session.getInstance(new Properties()), envelope)).isInstanceOf(MessagingException.class);
|
||||
var smtp = new EmailProviderSettings(new QRecord().withValue("kind", "SMTP").withValue("host", "127.0.0.1")
|
||||
.withValue("port", 1).withValue("security", "NONE"), new EmailQBitConfig());
|
||||
for(EmailTransport transport : List.of(new SmtpEmailTransport(), new SesEmailTransport(builder ->
|
||||
{
|
||||
throw new AssertionError("Invalid MIME must not reach the client");
|
||||
})))
|
||||
{
|
||||
assertThatThrownBy(() -> transport.send(envelope, transport instanceof SmtpEmailTransport ? smtp : settings()))
|
||||
.isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getPermanent()).isTrue();
|
||||
assertThat(error.getProviderLevel()).isFalse();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard legacy rows before resolving any secret or opening a connection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void smtpBlocksPlaintextAuthenticationBeforeConnecting()
|
||||
{
|
||||
for(String kind : List.of("SMTP", "GMAIL_SMTP"))
|
||||
{
|
||||
var row = new QRecord().withValue("kind", kind).withValue("host", "relay.example.com").withValue("port", 25)
|
||||
.withValue("security", "NONE").withValue("usernameEnvName", "EMAIL_SMTP_USERNAME");
|
||||
var config = new EmailQBitConfig().withSecretResolver(name ->
|
||||
{
|
||||
throw new AssertionError("Must reject before resolving credentials");
|
||||
});
|
||||
assertThatThrownBy(() -> new SmtpEmailTransport().send(envelope(), new EmailProviderSettings(row, config)))
|
||||
.hasMessage("SMTP sign-in needs STARTTLS or TLS").isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getProviderLevel()).isTrue();
|
||||
assertThat(error.getPermanent()).isFalse();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Settings Validate References And Missing Secrets.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsValidateReferencesAndMissingSecrets() throws Exception
|
||||
{
|
||||
var gmail =
|
||||
new EmailProviderSettings(
|
||||
new QRecord().withValue("kind", "GMAIL_SMTP"), new EmailQBitConfig());
|
||||
assertThat(gmail.host()).isEqualTo("smtp.gmail.com");
|
||||
assertThat(gmail.port()).isEqualTo(587);
|
||||
assertThat(gmail.security()).isEqualTo("STARTTLS");
|
||||
var row = new QRecord().withValue("kind", "SMTP").withValue("secretEnvName", "INVALID");
|
||||
assertThatThrownBy(
|
||||
() -> new EmailProviderSettings(row, new EmailQBitConfig()).resolve("secretEnvName"))
|
||||
.hasMessageContaining("not allowed").isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getProviderLevel()).isTrue();
|
||||
assertThat(error.getPermanent()).isFalse();
|
||||
});
|
||||
row.withValue("secretEnvName", "EMAIL_SMTP_PASSWORD");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new EmailProviderSettings(row, new EmailQBitConfig().withSecretResolver(n -> null))
|
||||
.resolve("secretEnvName"))
|
||||
.hasMessageContaining("not set").isInstanceOfSatisfying(EmailTransportException.class, error ->
|
||||
{
|
||||
assertThat(error.getProviderLevel()).isTrue();
|
||||
assertThat(error.getPermanent()).isFalse();
|
||||
});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new EmailProviderSettings(
|
||||
row,
|
||||
new EmailQBitConfig()
|
||||
.withSecretResolver(
|
||||
n ->
|
||||
{
|
||||
throw new RuntimeException("secret");
|
||||
}))
|
||||
.resolve("secretEnvName"))
|
||||
.hasMessageContaining("not set");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
EmailMime.build(
|
||||
Session.getInstance(new Properties()),
|
||||
envelope().withHeaders(Map.of("X-Header", "bad\r\nvalue"))))
|
||||
.isInstanceOf(MessagingException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Phase (a), part 1. Apply before producing the qbit. Host security columns must be added by the host.
|
||||
|
||||
CREATE TABLE email_provider (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
enabled BOOLEAN,
|
||||
is_default BOOLEAN,
|
||||
from_address VARCHAR(500),
|
||||
from_name VARCHAR(500),
|
||||
reply_to VARCHAR(500),
|
||||
sending_domain VARCHAR(500),
|
||||
host VARCHAR(500),
|
||||
port INTEGER,
|
||||
security VARCHAR(500),
|
||||
region VARCHAR(500),
|
||||
username_env_name VARCHAR(500),
|
||||
secret_env_name VARCHAR(500),
|
||||
webhook_secret_env_name VARCHAR(500),
|
||||
sns_topic_arn VARCHAR(500),
|
||||
configuration_set VARCHAR(500),
|
||||
message_stream VARCHAR(500),
|
||||
max_per_minute INTEGER,
|
||||
max_per_day INTEGER,
|
||||
track_opens BOOLEAN,
|
||||
track_clicks BOOLEAN,
|
||||
phi_allowed BOOLEAN,
|
||||
health_status VARCHAR(500),
|
||||
last_success_date TIMESTAMP,
|
||||
last_failure_date TIMESTAMP,
|
||||
last_failure_message VARCHAR(500),
|
||||
dispatch_lock INTEGER,
|
||||
default_slot INTEGER,
|
||||
UNIQUE (name),
|
||||
UNIQUE (default_slot)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
status VARCHAR(500),
|
||||
to_addresses TEXT,
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
from_address VARCHAR(500),
|
||||
reply_to VARCHAR(500),
|
||||
subject TEXT,
|
||||
template_id INTEGER,
|
||||
template_version_id INTEGER,
|
||||
brand_id INTEGER,
|
||||
category VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
variables_snapshot TEXT,
|
||||
html_body TEXT,
|
||||
text_body TEXT,
|
||||
body_sha256 VARCHAR(500),
|
||||
idempotency_key VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
provider_message_id VARCHAR(500),
|
||||
attempt_count INTEGER,
|
||||
next_attempt_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
finished_date TIMESTAMP,
|
||||
sent_date TIMESTAMP,
|
||||
delivered_date TIMESTAMP,
|
||||
dead_letter_date TIMESTAMP,
|
||||
linked_table_name VARCHAR(500),
|
||||
linked_record_id VARCHAR(500),
|
||||
sender_qbit VARCHAR(500),
|
||||
sender_process VARCHAR(500),
|
||||
suppressed_addresses TEXT,
|
||||
send_after TIMESTAMP,
|
||||
UNIQUE (idempotency_key)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message_attempt (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
message_id INTEGER NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
outcome VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
claimed_by VARCHAR(500),
|
||||
start_date TIMESTAMP,
|
||||
end_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
duration_millis INTEGER,
|
||||
error VARCHAR(500),
|
||||
UNIQUE (message_id, attempt_number)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
category VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
brand_id INTEGER,
|
||||
active_version_id INTEGER,
|
||||
phi_approved BOOLEAN,
|
||||
phi_approved_by VARCHAR(500),
|
||||
phi_approved_date TIMESTAMP,
|
||||
edit_permission_name VARCHAR(500),
|
||||
owner_qbit VARCHAR(500),
|
||||
description TEXT,
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template_version (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
template_id INTEGER,
|
||||
version_number INTEGER,
|
||||
status VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
subject TEXT,
|
||||
preheader TEXT,
|
||||
body_source TEXT,
|
||||
text_source TEXT,
|
||||
compiled_html TEXT,
|
||||
variables_schema TEXT,
|
||||
sample_variables TEXT,
|
||||
created_by VARCHAR(500),
|
||||
activated_date TIMESTAMP,
|
||||
UNIQUE (template_id, version_number, locale)
|
||||
);
|
||||
|
||||
CREATE TABLE email_brand (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
logo_url VARCHAR(500),
|
||||
logo_dark_url VARCHAR(500),
|
||||
logo_alt VARCHAR(500),
|
||||
primary_color VARCHAR(500),
|
||||
text_color VARCHAR(500),
|
||||
background_color VARCHAR(500),
|
||||
dark_background_color VARCHAR(500),
|
||||
dark_text_color VARCHAR(500),
|
||||
button_text_color VARCHAR(500),
|
||||
footer_markdown TEXT,
|
||||
postal_address TEXT,
|
||||
social_links TEXT,
|
||||
site_url VARCHAR(500),
|
||||
is_default BOOLEAN,
|
||||
app_name VARCHAR(500),
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_suppression (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
address VARCHAR(500),
|
||||
scope VARCHAR(500),
|
||||
reason VARCHAR(500),
|
||||
source_message_id INTEGER,
|
||||
source_event_id INTEGER,
|
||||
note TEXT,
|
||||
created_date TIMESTAMP,
|
||||
expires_date TIMESTAMP,
|
||||
UNIQUE (address, scope)
|
||||
);
|
||||
|
||||
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
|
||||
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
|
||||
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_ERR">
|
||||
<LevelMatchFilter level="OFF" onMatch="DENY" onMismatch="NEUTRAL"/>
|
||||
<PatternLayout pattern="%level %logger{1}: %message%n"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Logger name="com.icegreen.greenmail" level="OFF"/>
|
||||
<Root level="WARN"><AppenderRef ref="Console"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
Reference in new issue
Block a user