Files
james.maesandClaude Opus 5.5 acff558a9a feat(api): fold EmailEffects into EmailApi
EmailApi.sendOnce(request, effectKey) with EmailApiEffectOutcome and
EmailApiEffectResult, lookups by idempotency key and RFC Message-ID, and
rfcMessageId on EmailApiMessage, so other qbits have one public surface.
EmailEffects and its types stay as internal, deprecated delegates; the
durable-jobs guide, README and DESIGN 2.5 point at EmailApi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:41:29 -05:00

582 lines
42 KiB
Markdown

# qbit-email design
**Status:** phases (a), (b) and (c) implemented, except Preferences (8.3), deferred until core #1162 ships in an RC. Phase (d) consumer migrations and phase (e) inbound remain specification.
**Brief:** owner decisions of 2026-10-13 (providers, outbound first, both template kinds).
**Owner decisions (2026-10-12): all recommendations accepted.** Every "Recommended" choice and
every decision below stands; it is adjusted once it can be seen working.
One email service for a whole QQQ system. Every qbit and app that sends mail queues it through
this qbit's outbox and gets the same providers, templates, branding, tracking, suppression,
unsubscribe and PHI rules.
| # | Decision |
|---|---|
| D1 | Build on core's messaging SPI (`MessagingProviderInterface`, `SendMessageAction`). The qbit registers one provider type, `QBIT_EMAIL`, that writes to the outbox, so existing `SendMessageAction` callers move by pointing their `messagingProviderName` at it (section 2.4). Core's built-in `EMAIL` and `SES` types are not overridden. |
| D2 | Delivery transports live in the qbit (section 3): core's SMTP provider has no auth, STARTTLS or timeouts, and its SES provider uses AWS SDK v1 with static keys in metadata. No core PR is required; the transports can move to core later without changing the tables. |
| D3 | Providers are data rows (`email_provider`). Credentials are environment-variable NAMES, never values (the qbit-assistant rule). |
| D4 | Every send goes through the outbox (`email_message`); nothing sends inline. Async, at least once, retries with backoff, dead letters, idempotency keys. |
| D5 | Two template kinds: Markdown with `{{variables}}` inside the branded responsive layout (the default), and raw MJML or HTML (marketing). Templates are versioned. |
| D6 | Tracking events (SES via SNS, Postmark, SendGrid, Mailgun) arrive at `/qqq/hooks/email/...` (not the UI API), are signature-verified, and land in a durable inbox before processing. |
| D7 | Hard bounces and complaints suppress the address. One-click unsubscribe (RFC 8058) records a suppression for the message's category, and users see it as a Preferences section (core #1162). |
| D8 | PHI-safe by default: PII/PHI-classified variables are refused unless the template is `phi_approved`. The recommended PHI pattern is a "secure message" (a sign-in link, no PHI in the body). |
| D9 | Gmail: SMTP with an app password in phase (b), as a preset of the SMTP transport. The Gmail API with OAuth is specified (section 3.6) and built only on request. |
| D10 | MJML is compiled when a version is activated, by a pluggable compiler (default: the `mjml` CLI when configured, through stdin and stdout). Sends never need the compiler; HTML templates need nothing. |
| D11 | Owner rule (2026-10-13): no files or filesystem, only the database and S3. Attachments, rendered bodies and (phase e) inbound mail live in the database or in object storage (qbit-object-storage), never on local disk; the services are stateless, so any replica can send, receive a webhook or render a preview. |
## 1. Flow
```
EmailService.send(EmailRequest) ─┐
email_send_templated (process) ─┤
SendMessageAction (type QBIT_EMAIL) ─┼─► EmailOutbox.enqueue ─► email_message (QUEUED; rendered, redacted)
record action "Send Test Email" ─┘ idempotency key unique; suppression and PHI checks first
│
scheduler (every 15 s) ─► EmailDispatcher.runOnce
claim attempt n (unique insert into email_message_attempt)
─► provider rate limit ─► EmailTransport.send (SMTP | SES | Postmark | SendGrid | Mailgun | Gmail)
─► SENT (provider message id) │ RETRY_WAIT (backoff) │ DEAD_LETTER
│
provider webhook ─► /qqq/hooks/email/{kind}/{providerName} ─► verify ─► email_webhook_inbox (durable)
─► inbox processor ─► email_event (DELIVERED, BOUNCED, COMPLAINED, OPENED, CLICKED, ...)
─► message status ─► email_suppression (hard bounce, complaint)
List-Unsubscribe one-click POST ─► /qqq/hooks/email/unsubscribe/{token} ─► email_suppression (category)
```
## 2. Sending API
### 2.1 `EmailService` (internal Java enqueue service)
```java
EmailSendResult result = EmailService.send(new EmailRequest()
.withTemplateName("alerts_notification") // or withSubject + withMarkdownBody / withHtmlBody for ad-hoc mail
.withTo("pat@example.com") // withCc, withBcc, withReplyTo; "addr" or "Name <addr>"
.withVariable("count", 3) // plain values, checked against the template's schema
.withRecordVariable("order", "order", 1234) // a record, read as the sender; classification known
.withCategory(EmailCategory.NOTIFICATION) // default: the template's
.withProviderName("pmg") // optional; else the default provider
.withFromAddressId(4) // optional enabled email_provider_sender_address row of that provider
.withBrandName("kof22") // optional; else the template's, else the default brand
.withIdempotencyKey("alerts:notif:991") // optional; a repeat returns the existing message
.withLinkedRecord("alerts_alert", 991) // shown as a link on the message record
.withSender("qbit-alerts", "alerts_notify") // sender qbit and process
.withSendAfter(instant)); // optional deferral
// EmailSendResult { messageId, status (QUEUED | DUPLICATE | SUPPRESSED | REFUSED), reasons }
```
- Runs inside the caller's transaction when the backends match (the row commits with the caller's
write), else after commit, as qbit-webhooks does.
- Validation is synchronous: unknown template, missing required variables, bad addresses, a
PII/PHI variable on a template that is not `phi_approved` (section 7), and a disabled or missing
provider all return `REFUSED` with plain reasons, and nothing is queued.
- All recipients suppressed for the category: `SUPPRESSED`; the message is stored with that status
(visible in the outbox) and not sent. Partly suppressed: those addresses are dropped and listed
on the message.
### 2.2 Process `email_send_templated`
The same request as a backend process (template, to, cc, bcc, variables JSON, linked table and
id, idempotency key, provider by `providerId` or `providerName`, `fromAddressId` filtered by the
chosen `providerId`, brand) for scripts, the API and other qbits. Not in the nav;
callable through the API and `/qqq/v1/processes`.
### 2.3 Permissions
`email.view` (read every `email_` table), `email.send` (send through the API or the process;
system callers use the authorized scope), `email.manage` (templates, brands, suppressions, resend,
cancel), `email.admin` (providers, test send, PHI approval, removing complaint suppressions). A
template may set `edit_permission_name` to restrict who edits it (e.g. marketing templates to
`marketing.manage`).
### 2.4 Core messaging bridge
- `EmailOutboxMessagingProviderMetaData` (type `QBIT_EMAIL`, a `QMessagingProviderMetaData`)
registers `EmailOutboxMessagingProvider` with `QMessagingProviderDispatcher`. The producer adds
one instance named `qbitEmail` (configurable).
- `sendMessage(SendMessageInput)` maps the parties (`EmailPartyRole` TO/CC/BCC/FROM), subject and
contents (`EmailContentRole` TEXT/HTML) to an ad-hoc `EmailRequest` (no template, category
TRANSACTIONAL unless configured) and enqueues it. It throws on `REFUSED`, so callers still see
failures; delivery is then asynchronous.
- Attachments are stored through qbit-object-storage when it is installed, else refused with a
plain message (no blobs in the outbox, nothing on local disk: D11).
- Callers (qbit-alerts `EmailChannel` and `NotificationEmailer`, qbit-marketing
`QqqMessagingEmailTransport`) can switch by configuration at once, then move to `EmailApi`
in phase (d) for templates, categories, idempotency and linked records.
### 2.5 Public API
`com.kingsrook.qbits.email.api.EmailApi` is the sole public qbit-email surface for consuming qbits.
Changes within a `0.x` minor release line are additive only. Public/protected
signatures contain only JDK, QQQ core or API-package types; internal packages are
not API and may change. A reflection test discovers every compiled API class and
checks signatures, including generic arguments, bounds and nested types.
- `EmailApi.send(EmailApiRequest)` maps to the existing outbox service, preserving
permissions, schema/PHI validation, suppression and one-recipient rules. Requests
cover recipients/sender selection, body alternatives, template variables and record
provenance, category/provider/brand/locale, idempotency, linked records, producer
identity, scheduling and caller transactions (`withTransaction(transaction, backendName)`).
Results expose message id, enqueue status and reasons.
- `sendOnce(EmailApiRequest, effectKey)` provides durable handoff through the existing
internal effects implementation. The key becomes the request's idempotency key;
null requests, missing/blank/over-500-character keys, mismatches and caller transactions
are refused. `EmailApiEffectResult` carries `EmailApiEffectOutcome.QUEUED_OR_DUPLICATE`
or `REFUSED` and nullable `EmailApiMessage`. Success means committed outbox storage,
and replays retain the row and RFC Message-ID. Suppressed audit rows remain refused
on replay and return metadata; refusals without a row return null. Operational
failures throw `QException`. The deprecated `EmailEffects`, `EmailEffectOutcome` and
`EmailMessageRef` remain internal; consumers use this API. See [durable jobs](DURABLE-JOBS.md).
- `ensureTemplate(EmailApiTemplate)` delegates create-once template registration;
`EmailApiVariable` carries the variable contract. Existing content is never
overwritten, and the API does not confer PHI approval.
- `getMessage`, exact `getMessageByIdempotencyKey`/`getMessageByRfcMessageId` and
bounded, literal `findMessagesByIdempotencyKeyPrefix` perform
system reads of an explicit metadata projection: id/status/category, idempotency
key, RFC Message-ID and provider id, template name, linked record and create/sent/delivered dates.
Exact-key lookups return empty for null/blank/missing values. RFC lookup requires
the exact bracketed header stored at enqueue time, distinct from provider response ids.
No recipients, subjects, bodies or snapshots are fetched or returned by lookups.
Callers own authorization before presenting metadata to users.
- `isInstalled`, `defaultProviderName`, `listSenderAddresses` and `readiness` replace
consumer access to runtime/configuration/tables. Readiness checks an enabled
default provider; MARKETING also needs a valid publicBaseUrl, available unsubscribe
key and default brand postal address. The PHI overload checks body key availability
and provider PHI/tracking policy. Reasons never contain resolved secret values.
PHI is a template state, so it is an explicit option rather than a fifth category.
- Database operations use the current `QContext`; an installed instance argument
must match that context. Database errors remain core `QException`. Readiness does
not validate an individual request or guarantee delivery.
For simple subject/body sends, consumers need no qbit-email dependency: use core
`SendMessageAction` with `messagingProviderName = "qbitEmail"` (or the host's
configured bridge name). Use the API package for templates, idempotency, links and
lookups. Both routes enqueue; neither sends inline.
## 3. Providers and transports
### 3.1 `email_provider`
| Field | Notes |
|---|---|
| name, label | unique name, used by `withProviderName` |
| kind | SMTP, SES, POSTMARK, SENDGRID, MAILGUN, GMAIL_SMTP, GMAIL_API |
| enabled, is_default | exactly one enabled default (validated) |
| from_address, from_name, reply_to | defaults for messages |
| sending_domain | the domain the provider is verified for; other from domains are refused |
| host, port, security | SMTP: NONE, STARTTLS, TLS; timeouts fixed (10 s connect, 15 s read and write) |
| region | SES region; Mailgun region (US or EU) |
| username_env_name, secret_env_name | env-var NAMES only (`^[A-Z_][A-Z0-9_]{0,99}$`, and in the config allowlist) |
| webhook_secret_env_name | env-var NAME for webhook verification (Mailgun signing key, SendGrid public key, Postmark basic-auth password) |
| sns_topic_arn, configuration_set, message_stream | SES events topic and configuration set; Postmark message stream |
| max_per_minute, max_per_day | counted from `email_message_attempt` in the database, so they hold across replicas |
| track_opens, track_clicks | off by default; refused for `phi_approved` templates |
| phi_allowed | admin attestation that the provider may carry PHI (BAA in place, or an internal relay) |
| health_status, last_success_date, last_failure_date, last_failure_message | kept by the dispatcher |
Secrets resolve through `EmailQBitConfig.secretResolver` (default `System::getenv`) at send time.
They are never stored, logged, audited, put in process output or returned. The record screen shows
whether each named variable is set (yes or no), never the value.
**Record action "Send Test Email"** (`email.admin`): queues a fixed test message to a given
address through that provider and reports the outcome on the standard result screen. An optional
`fromAddressId` picker lists only that provider's sender addresses.
**Sender addresses** (`email_provider_sender_address`, a child list on the provider, `email.admin`):
provider_id, address (validated, lowercase), display_name, is_default (at most one enabled default
per provider), enabled, note; unique (provider_id, address); the domain must equal `sending_domain`
when that is set. The provider's `from_address` / `from_name` remain the fallback; an enabled default
row wins when present. A chosen id, or an explicit from text once the provider has rows, must match an
enabled row of the resolved provider or the send is `REFUSED` ("That from address is not set up for
provider <name>"). The row used is kept as `email_message.from_address_id`.
### 3.2 `EmailTransport` SPI (in the qbit)
```java
public interface EmailTransport
{
EmailTransportResult send(EmailEnvelope envelope, EmailProviderSettings settings) throws EmailTransportException;
}
```
`EmailEnvelope` holds the rendered MIME parts: from, to, cc, bcc, reply-to, subject, HTML, text,
and headers (`List-Unsubscribe`, `Auto-Submitted`, `X-QQQ-Email-Id`). The result is the
provider's message id. The exception says whether the failure is **permanent** (rejected address,
sender refused, 4xx validation) or **transient** (connection, 5xx, throttling with an optional
retry-after).
### 3.3 SMTP (phase a)
`jakarta.mail` (already in core): auth when a username env name is set, STARTTLS or implicit TLS,
connect/read/write timeouts (the kof22 `Mailer` lesson: a stuck relay must not pin a thread).
Covers PMG (`smtp.galaxy.lan:26`, no auth), any relay, and Gmail SMTP.
### 3.4 SES (phase a)
AWS SDK v2 `sesv2` `SendEmail` with raw content, so our MIME and headers are kept. Credentials:
the default AWS chain when no env names are set (IRSA, instance role, `AWS_*`), else the named
access-key and secret env vars. The configuration set routes events to SNS (section 6).
### 3.5 Postmark, SendGrid, Mailgun (phase b)
HTTP JSON APIs through `java.net.http`, no vendor SDKs: Postmark `POST /email` (server-token
header, message stream), SendGrid `POST /v3/mail/send` (bearer key; `X-Message-Id`), Mailgun
`POST /v3/{domain}/messages.mime` (basic auth `api:key`, US or EU base). 429 and 5xx are
transient; other 4xx are permanent.
### 3.6 Gmail
- **Phase (b): `GMAIL_SMTP`**, a preset of SMTP (`smtp.gmail.com:587`, STARTTLS) with the account
as username and an app password env var. `docs/PROVIDERS.md` documents it: it needs 2-step
verification and an app password; Google's daily limits (about 500 for a personal account, 2000
for Workspace) go in `max_per_day`.
- **`GMAIL_API` (specified, built on request):** OAuth client id, secret and refresh token (env
names), `users.messages.send` with the raw MIME. It needs a Google Cloud project and consent
screen and gives system mail nothing the app password does not, so it is deferred.
## 4. Outbox and delivery
### 4.1 `email_message`
| Field | Notes |
|---|---|
| status | QUEUED, SENDING, SENT, DELIVERED, RETRY_WAIT, DEAD_LETTER, BOUNCED, COMPLAINED, SUPPRESSED, CANCELLED |
| to_addresses, cc_addresses, bcc_addresses, from_address, reply_to | addresses are PII; the table is classified PII (section 7) |
| subject | rendered; encrypted for `phi_approved` templates (section 7.3) |
| template_id, template_version_id, brand_id, category, locale | |
| variables_snapshot | JSON, redacted per section 7.3 |
| html_body, text_body | rendered at queue time so retries send the same bytes; encrypted for `phi_approved`; cleared after `bodyRetentionDays` (default 30) once final, keeping `body_sha256` |
| idempotency_key | unique when present |
| rfc_message_id | STRING(500), unique; stored once at enqueue and reused on every attempt; see [durable job effects](DURABLE-JOBS.md) |
| provider_id, provider_message_id | |
| attempt_count, next_attempt_date, lease_expires_date, last_error, sent_date, delivered_date, dead_letter_date | |
| linked_table_name, linked_record_id | rendered as a link on the record screen |
| sender_qbit, sender_process | |
| suppressed_addresses | addresses dropped at queue time |
Sub-tables, shown as relations on the message: `email_message_attempt` (message_id and
attempt_number unique as a pair, outcome, provider_id, claimed_by, start and end dates, duration,
error) and `email_event` (section 6).
### 4.2 Semantics (as qbit-webhooks, proven across replicas)
- **Claim:** to send attempt *n* a runner inserts (`message_id`, *n*) into
`email_message_attempt`; the pair is unique, so exactly one runner sends.
- **Lease:** the winner marks the message SENDING with a 120 s lease; an expired lease is retried
as attempt *n+1*.
- **At least once:** a runner that dies after the provider accepted, but before recording it,
causes at most one repeat. None of the providers offers a send-idempotency key, so the spec says
so plainly.
- **Outcomes:** success → SENT. Transient → RETRY_WAIT after `retryMinutes` (1, 5, 15, 60, 240,
720; retry-after honoured, capped at 1 h). Permanent → DEAD_LETTER at once. After `maxAttempts`
(6) → DEAD_LETTER.
- **Rate limits** per provider per minute and per day, from the database. Over a limit the message
waits; that is not an attempt.
- **Provider health:** HEALTHY → UNHEALTHY after 10 consecutive transient failures; while
unhealthy one message is tried every 5 minutes; a success → HEALTHY. Alerts (optional, section
9): `email.provider.unhealthy` (ERROR, resolved on recovery) and `email.message.dead_letter`
(WARNING, deduplicated per provider per hour).
- **Record actions:** Resend (dead letters and bounces: a new linked message with the same
content), Cancel (queued or waiting), Send Now (waiting). Standard streamed-ETL Review and Result
screens.
- **Retention:** a daily job clears bodies after `bodyRetentionDays` and deletes final messages
older than `messageRetentionDays` (0 = keep forever; kof22 keeps). NOTIFICATION and MARKETING
messages are never deleted, because their unsubscribe links resolve through the message row
(8.2); at `messageRetentionDays` retention only clears their bodies and variable snapshot.
## 5. Templates and branding
### 5.1 Tables
- `email_template`: name (unique, snake_case), label, category (TRANSACTIONAL, NOTIFICATION,
MARKETING, SECURITY), kind (MARKDOWN, MJML, HTML), brand_id, active_version_id, phi_approved,
phi_approved_by, phi_approved_date, edit_permission_name, owner_qbit, description.
- `email_template_version` (child list on the template): template_id, version_number, status
(DRAFT, ACTIVE, RETIRED), locale (default `en`), subject, preheader, body_source, text_source
(optional), compiled_html (MJML), variables_schema (JSON), sample_variables (JSON), created_by,
activated_date.
- `email_brand`: name, label, logo_url, logo_dark_url, logo_alt, primary_color, text_color,
background_color, dark_background_color, dark_text_color, button_text_color, footer_markdown,
postal_address (shown on marketing mail, for CAN-SPAM), social_links (JSON list of label and
url), site_url, is_default, app_name (optional: the brand for that app's sends).
An ACTIVE version cannot be edited. **Record action "New Version"** copies it to a DRAFT;
**"Activate Version"** validates it (schema, a render with the samples, the PHI check, MJML
compile, brand contrast) and makes it active, retiring the previous one. A message always records
the exact version it used.
### 5.2 Rendering
- **Variables:** `{{name}}` and dotted paths (`{{order.number}}`), always HTML-escaped;
`{{#if name}}…{{/if}}` and `{{#each items}}…{{/each}}`; `{{name|fallback text}}`. No unescaped
output and no helpers that run code. Unknown variables fail activation, not sends.
- **Schema:** each variable has name, label, type (STRING, NUMBER, DATE, DATE_TIME, BOOLEAN, URL,
LIST, RECORD), required, an optional classification, and an optional bound `table` and `field`
(the classification then comes from the field, section 7).
- **Markdown** (commonmark-java with GFM tables and autolinks; raw HTML in Markdown disabled)
renders into the branded layout: a fixed, tested, table-based responsive shell (600 px max,
single column, inline styles plus one `<style>` block for media queries) with the logo, a hidden
preheader, the body, a button style for links written `[Label](url){.button}`, and the footer
(brand footer, postal address for MARKETING, the unsubscribe link when it applies).
- **Dark mode:** the layout declares `color-scheme: light dark` and `supported-color-schemes`,
switches to the brand's dark colours under `@media (prefers-color-scheme: dark)` (and the
`[data-ogsc]` hooks Outlook uses), uses the dark logo when set, never puts text over a
background image, and keeps text and button contrast at WCAG AA in both schemes. Activation
checks the brand colours and refuses a brand that fails, quoting the ratio.
- **MJML:** compiled at activation by `MjmlCompilerInterface` (default `CliMjmlCompiler`, which
runs the configured `mjml` binary with a timeout). With no compiler configured, MJML versions
cannot be activated, and say so. The compiled HTML is stored on the version.
- **HTML:** used as given; variables are still substituted and escaped. The qbit adds the
unsubscribe footer and headers for MARKETING and NOTIFICATION.
- **Plain text** is generated from the rendered HTML (links as `text (url)`, headings and lists
kept) unless the version has its own text.
- **i18n-ready:** versions carry a locale; `EmailRequest.withLocale` picks that locale's active
version, else the default locale's. One locale (`en`) ships.
### 5.3 Preview
**Part 2 status:** standard record widget implemented. Next's sanitizer removes
iframes and style blocks, so it uses sanitized inline light (390px maximum) and dark
(600px maximum) samples, with wrapping plain text. Draft MJML previews compile in memory
with the configured compiler; stored versions are unchanged. TODO: endpoint arrives with RC.6.
Each version record shows a **Preview** widget: the rendered email with the sample variables in a
sandboxed iframe (`srcdoc`, scripts disabled), Light/Dark and 390/600 px toggles, and the plain
text. It is served through the UI API (section 10): `POST /qqq/v1/qbit/email/preview` (a version
id, or unsaved source, plus variables) returns subject, html, text and warnings.
### 5.4 Part 2 implementation choices
- Approval records `approved_version_id` on the template. An approved DRAFT freezes;
approve a new sensitive version before activation. Activating a different,
unapproved version clears approval. Approval is specific to one locale version.
- `EmailBuiltIns.ensure()` seeds `email_secure_message` on its first send, using the
database unique name and a transaction, with no static bootstrap cache.
- `dark_primary_color` and `dark_button_text_color` optionally override their light
counterparts; both button pairs are contrast-checked. Brand colors use #RRGGBB.
- `EmailTemplatedMessageInput` extends core SendMessageInput for schema variables and
record references. Ordinary core input remains compatible ad-hoc content.
- RC.5 QueryAction masks passwords but does not apply ordinary FieldSecurityLock;
record-variable loading additionally applies that lock to sender session keys.
- The real outbox id is bound during the enqueue transaction, before commit. Failed
validation persists no message. Resends preserve encrypted content and its original
token snapshot, linking to the original message for the token binding.
- The home follows the newer Alerts pattern: standard QQQ chart widgets plus inline,
theme-neutral cards. All queries/counts use the viewer's session. Bounce/complaint
dates are populated by phase-(c) events; home totals count those recipient events.
- Part 2 signs opt-out links and prepares headers; phase (c) now serves the receiver. Without its key/base URL, notification omits opt-out links and
headers, while marketing refuses as specified in 8.2. Do not use opt-out delivery
without a receiving handler. The token's address hash binds the single recipient mailbox.
## 6. Delivery tracking
### 6.1 Inbound route
`EmailWebhookRouteProvider` (a `QJavalinRouteProviderInterface`, registered idempotently as in
qbit-easypost-tracking) serves `POST /qqq/hooks/email/{kind}/{providerName}`: size cap (413), find
the enabled provider of that kind (404), verify (401 on failure, 503 when its secret is not set),
dedup-insert the raw payload into `email_webhook_inbox` (provider_id and external_event_id unique,
status PENDING, attempts, payload_json, payload_sha256, received_date) and answer 200. A scheduled
process drains the inbox into `email_event`.
| Kind | Verification |
|---|---|
| SES (SNS) | SNS message signature (SignatureVersion 1 and 2) with the certificate from `SigningCertURL`, which must be `https://sns.<region>.amazonaws.com/...pem`; the topic ARN must be the provider's. `SubscriptionConfirmation` is confirmed by fetching `SubscribeURL` (same host rule). |
| Postmark | HTTP basic auth on the webhook URL (Postmark does not sign); password from the webhook secret env name, compared in constant time. |
| SendGrid | Signed Event Webhook: ECDSA P-256 over timestamp + body with the public key from the env name; timestamp within 5 minutes. |
| Mailgun | HMAC-SHA256 of timestamp + token with the signing key; timestamp within 5 minutes; a token seen before is refused. |
### 6.2 `email_event`
message_id (matched by provider message id or our header), provider_id, event_type (DELIVERED,
DEFERRED, BOUNCED, COMPLAINED, OPENED, CLICKED, UNSUBSCRIBED, REJECTED), bounce_type (HARD, SOFT,
UNDETERMINED), recipient, occurred_date, detail (the provider's reason, truncated; never body
content), url (clicks only). Opens and clicks are stored only when the provider row enables them.
### 6.3 Phase (c) implementation
- Inbox states: PENDING, PROCESSED, FAILED, IGNORED. Retry after configured backoff,
FAILED after six attempts, and a standard Reprocess action resets FAILED to PENDING.
- Events in one payload are applied independently. Only unmatched events (or events whose
message is still SENDING) defer the row; on the sixth attempt they are skipped and the row is
PROCESSED with the note "N events did not match a message". Suppression applies only to a
recipient in the matched message's to, cc or bcc; an SES bounce or complaint without its
recipient list is skipped, never widened to all destinations. SNS notifications must carry a
Timestamp no older than 1 hour and no more than 5 minutes ahead, and certificate fetch
failures are cached for 5 minutes per URL.
- A short DB transaction locks the common provider gate then the actual provider;
it rechecks the observed inbox attempt and commits all changes together. No worker
filesystem, extra claim table, in-process scheduler or network operation is needed
during processing. A crash rolls back the transaction and releases the claim.
- `email_event.event_key` provides unique recipient/event deduplication across
overlapping batches, alongside inbox uniqueness on provider/event and provider/nonce.
- Message correlation always stays within the authenticated provider. Unknown message
ids retry (including callbacks before SENT); unsupported event kinds are ignored.
- Opens/clicks require current provider flags and are also dropped for PHI-approved
messages. Event details mask addresses before truncation to 500 characters.
- The unsubscribe signer binds one mailbox. A valid POST suppresses only that address
for MARKETING/NOTIFICATION; the page never displays addresses. Legacy links remain
valid only for messages with one To recipient and no Cc/Bcc.
- PostgreSQL and H2 definitions, crypto fixtures and real random-port Javalin tests
are included. Certificate caching is memory-only. Setup is in `PROVIDERS.md`.
## 7. PHI and PII
### 7.1 Classification
Variables bound to a field (schema `table` + `field`, or `withRecordVariable`) take that field's
classification from core `AiDataClassifications.ofField` (`DataClassification` PUBLIC,
CONFIDENTIAL, PII, PHI; core #1142, first in 4.1.0-RC.6). Until RC.6 the qbit uses a stand-in,
`EmailClassifications` (reflective lookup of the core class, else config-declared
classifications), marked for replacement. Unbound variables may be declared PII or PHI in the
schema.
### 7.2 Refusal
- Activation and sending refuse a template whose variables include PII or PHI unless it is
`phi_approved`. Only `email.admin` sets that, through the record action "Approve For PHI", which
records who and when; a new version needs approval again.
- A `phi_approved` template may not use open or click tracking, may not be MARKETING, and must go
through a provider marked `phi_allowed`.
- **The default PHI pattern** is the built-in template `email_secure_message`: "You have a new
secure message from {{brand.label}}. Sign in to read it." with a button to the link the caller
passes. No PHI in the body, so it needs no approval. Consumers that hold PHI use it.
### 7.3 Redaction and storage
Following core `docs/SECURED-DATA.md` (section 15, QRun-IO/qqq#1161):
- `variables_snapshot`: PII/PHI values become tokens in the format of core
`ClassifiedValueTokens` (package `...core.actions.permissions.fieldaccess`, SECURED-DATA §14.5):
`hmac:v1:<keyId>:<b64url(HMAC-SHA256(key, m))>`, with `m` joined by `` and the canonical
value last, and the instance key from `QQQ_AUDIT_HMAC_KEY` (base64).
- **Record-bound variables** use the record binding, `m = table␟field␟pk␟canonical(value)`
(`forRecordField`), so the token matches the audit's and a field under a wipe rule gets
`shred:v1` with the same per-record-field key: a wipe also shreds the outbox snapshot.
- **Other values** use a domain-prefixed binding,
`m = "email"␟template␟variable␟messageId␟canonical(value)` (`forContext`), so they never
collide with a record binding. They get `hmac:v1` only; a low-entropy value (booleans, values
with under 4 distinct characters) gets `changed:v1`, because there is no record key to shred.
- No key configured: `changed:v1`.
- Until core ships the helper, the qbit carries `EmailValueTokens`, marked
`STAND-IN(core=#1161-impl, removeBy=RC.7)`, with the same names and output.
- The subject and bodies of `phi_approved` messages are stored AES-256-GCM encrypted with a key
from `QQQ_EMAIL_BODY_KEY` (no key: `phi_approved` sends are refused) and cleared after
`bodyRetentionDays` once final.
- Logs carry the message id and status only, never addresses, subjects or bodies.
- Record variables are read as the sending user, through core masking, so a value the sender may
not see arrives masked.
- **Canary-PHI egress test:** plants `CANARY-PHI-<random>` in a PHI field, sends through every
path (EmailService, the process, the core bridge, retry, dead letter, webhook events, an export
of `email_message`, the home widget, captured logs) and fails on any substring of the canary
longer than 4 characters outside the encrypted columns. It follows the core procedure so it can
join the shared canary suite when that exists.
## 8. Suppression and unsubscribe
### 8.1 `email_suppression`
address (lowercased), scope (ALL or a category), reason (HARD_BOUNCE, COMPLAINT, UNSUBSCRIBE,
MANUAL), source_message_id, source_event_id, note, created_date, expires_date (optional). Unique on
(address, scope). `email.manage` adds and removes rows; removing a COMPLAINT needs `email.admin`.
- Hard bounce → ALL. Complaint → ALL. Unsubscribe → the message's category.
- TRANSACTIONAL and SECURITY mail ignores UNSUBSCRIBE rows (they cannot be opted out of) but
respects HARD_BOUNCE and COMPLAINT.
### 8.2 One-click unsubscribe (RFC 8058, RFC 2369)
- NOTIFICATION or MARKETING messages carrying an unsubscribe link/header must have exactly one To recipient and no Cc/Bcc; send one message per recipient.
- MARKETING and NOTIFICATION messages carry
`List-Unsubscribe: <https://{publicBaseUrl}/qqq/hooks/email/unsubscribe/{token}>` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and the footer links to the same URL.
- The token is `v1.<b64url(payload)>.<b64url(HMAC-SHA256)>` over the message id, an address hash
and the category, keyed by `QQQ_EMAIL_UNSUBSCRIBE_KEY`. With no key there is no header and
MARKETING sends are refused. Tokens do not expire (RFC 8058 expects the link to keep working).
- `POST` with `List-Unsubscribe=One-Click` records the suppression and returns 200. `GET` returns a
minimal page with one "Unsubscribe" button that POSTs, because mail scanners prefetch GETs; then
"You are unsubscribed from {category}". A Preferences link awaits core #1162.
- An `UNSUBSCRIBED` event is recorded on the message.
### 8.3 Preferences (core #1162)
**Deferred: core #1162 is not in an RC.** The planned qbit registers a Preferences section `email` ("Email") whose provider
(`EmailPreferenceProvider implements UserPreferenceProviderInterface`) maps the signed-in user's
address (`UserPreferenceRequestContext.getUserEmailAddress()`, never an address from the request)
to `email_suppression` rows: one toggle per opt-out-able category (NOTIFICATION,
MARKETING) and read-only rows saying that account and security mail is always sent. Qbits with
finer settings keep their own sections: qbit-alerts' "Notifications" decides which alerts are
emailed; this section decides whether NOTIFICATION mail reaches the address at all. It is built
once #1162 is in an RC; one-click unsubscribe does not depend on it. With no address on the
account, the rows are read-only ("No email address on your account") and writes are refused. The
section's description explains the split with qbit-alerts' Notifications section.
## 9. Integrations
- **Alerts (core `QAlerts`, #1108):** called reflectively when present (section 4.2).
- **ESB and Quick Search:** `email_message`, `email_template` and `email_suppression` publish table
events and are searchable (`EmailIntegrations`); bodies and snapshots are excluded.
- **API:** the same tables, permissions and customizers as the admin (QQQ API middleware).
- **Object storage (#1089):** attachments (section 2.4) and, in phase (e), inbound attachments.
## 10. App and UI
- App **Email**, with one home dashboard (one HTML widget, real data only, inline styles, no inner
or horizontal scroll, stacked cards under 640 px, light and dark):
- sent, delivered, dead letters, bounced and complained in the last 24 h and 7 days;
- sends per hour for 24 h (sent and failed);
- suppression growth (new rows per day for 30 days, by reason);
- provider health (status, last success, last failure reason, rate-limit headroom);
- recent messages (newest 20: when, recipient masked as `p***@example.com`, subject unless
`phi_approved`, status), each linking to its record.
- Nav: Messages, Templates, Brands, Suppressions, Providers. Attempts, events, versions and inbox
rows are relations on their parents.
- UI API extension `email` (1.0.0, `/qqq/v1/qbit/email/...`): `POST /preview` (section 5.3), with
its OpenAPI fragment committed and contract-tested. Everything else is standard tables,
processes and widgets. The webhook and unsubscribe routes are non-UI (`/qqq/hooks/email/...`).
## 11. Consumers (phase d, one PR per repo)
| Consumer | Change |
|---|---|
| qbit-alerts | `EmailChannel` and `NotificationEmailer` use `EmailService` with templates `alerts_notification` and `alerts_digest` (NOTIFICATION), idempotency `alerts:<id>`, linked alert. `messagingProviderName` stays as the fallback when qbit-email is absent. |
| qbit-collaboration | an `EmailCollaborationNotifier` for mentions, shares and task reminders (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`); record labels obey `isRecordLabelFullAccess`. |
| qbit-assistant | scheduled-task results by email through `EmailService` (template `assistant_scheduled_result`), after its EXTERNAL approval rule. |
| qbit-easypost-tracking | delivery notices through a notifier that sends `easypost_delivery_notice` (NOTIFICATION). |
| qbit-marketing | a `MarketingTransport` over `EmailService` (MARKETING, MJML/HTML templates, unsubscribe headers, suppression honoured). |
| kof22 (Website-Backend) | the contact form's `Mailer` is replaced by `EmailService` with template `kof22_contact`, through an SMTP provider row for PMG (`smtp.galaxy.lan:26`, no auth, no new secrets). A draft PR with a Liquibase changeset for the `email_` tables and seed rows. |
| Authentik | **keeps its own SMTP to PMG.** It is a separate product (Python) with its own flows and templates (recovery, enrollment, MFA); it must send when the QQQ app is down, since it guards sign-in to that app; and it has no hook to call a Java service. Its mail is account and security mail, to which suppression-by-unsubscribe would not apply anyway. |
## 12. Phase 2: inbound (design only)
- Sources: SES inbound (receipt rule → S3 → SNS → `/qqq/hooks/email/inbound/ses/{name}`), Mailgun
routes, Postmark inbound, SendGrid inbound parse, and an IMAP or Gmail mailbox poller
(scheduled).
- `email_inbound`: provider, message id, in_reply_to, references, from, to, subject, text and html
(sanitized), received_date, status (NEW, ROUTED, UNMATCHED, REJECTED), routed_table, routed_id,
spam verdicts. Attachments go to qbit-object-storage (`email_inbound_attachment` rows).
- **Reply-to-record threading:** outbound NOTIFICATION mail sets
`Reply-To: reply+<token>@<inbound domain>`, where the token (HMAC) names the message and its
linked record. A reply is matched by token; the sender must be the original recipient; quoted
text is stripped; an `InboundEmailHandler` registered by the linked table's qbit acts (e.g.
qbit-collaboration adds a comment as that user). Unmatched mail stays UNMATCHED for review.
- PHI: inbound bodies are classified PHI by default (unknown content) and stored encrypted.
## 13. Data model summary
| Table | In the nav | Written by |
|---|---|---|
| `email_message` | yes | the qbit (people: record actions only) |
| `email_message_attempt` | relation | the qbit |
| `email_event` | relation | the qbit |
| `email_template` | yes | people (`email.manage`, per-template permission) |
| `email_template_version` | relation | people (drafts), actions (activation) |
| `email_brand` | yes | people |
| `email_suppression` | yes | people and the qbit |
| `email_provider` | yes | admins |
| `email_provider_sender_address` | relation (on the provider) | admins |
| `email_webhook_inbox` | relation (on the provider) | the qbit |
| `email_inbound`, `email_inbound_attachment` | phase (e) | the qbit |
Postgres DDL in `docs/ddl/email-postgres.sql` (phases a-c).
## 14. Configuration (`EmailQBitConfig`)
backendName (required), schedulerName (required for automatic processing), publicBaseUrl (unsubscribe links),
webhookBasePath (`/qqq/hooks/email`, shared by tracking and unsubscribe),
defaultProviderName, bridgeProviderName (`qbitEmail`), secretResolver, allowedEnvNames,
dispatchIntervalSeconds (15), batchSize (100), maxAttempts (6), retryMinutes, leaseSeconds (120),
bodyRetentionDays (30), messageRetentionDays (0), mjmlCompiler, classificationOverrides, appName
and appLabel, securityFields and recordSecurityLocks (multi-tenant hosts).
## 15. Phases and acceptance
| Phase | Scope | Acceptance (tests) |
|---|---|---|
| (a) | tables, outbox and dispatcher, SMTP and SES, Markdown/HTML/MJML templates and versions, brands and the layout, preview, EmailService, the process, the core bridge, PHI refusal and redaction, the app home | GreenMail SMTP sink end to end; SES through a mocked `SesV2Client`; the claim race (two runners, each message sent once); retry, dead letter, idempotency; rendering (variables, escaping, `#if`/`#each`, plain text, dark-mode contrast); PHI refusal; snapshot redaction; the canary test; producer standards (names, nav, permissions) |
| (b) | Postmark, SendGrid, Mailgun, Gmail SMTP | HTTP mocks per provider (success, 4xx permanent, 429/5xx transient); the Gmail preset. HTML/MJML moved into part (a), part 2 at owner request. |
| (c) | webhook route and inbox, events, suppression, unsubscribe (Preferences deferred) | signature verification per provider (valid, tampered, stale, missing secret); the SNS certificate host rule; suppression on hard bounce and complaint; one-click POST and the GET page; category rules |
| (d) | consumer migrations | each consumer's own tests; a contract test of `EmailService` |
| (e) | inbound | design only for now |
Dependencies: phase (a) builds on QQQ 4.1.0-RC.5 with the classification stand-in, and moves to
later RCs for `DataClassification` and Preferences when their APIs are released.
## 16. Owner steps (outside code)
- **PMG (kof22):** none; the relay already accepts the cluster.
- **SES (when a consumer adopts it):** create the SES identity for each sending domain in the
`terraform/dns-route53` root (owner-applied), request production access, and create a
configuration set with an SNS destination to `https://<app>/qqq/hooks/email/ses/<provider>`.
The DNS records (DKIM CNAMEs, a custom MAIL FROM subdomain with its MX and SPF; DMARC stays at
p=reject) go in as Praetor octoDNS PRs once the identity exists, because SES issues the DKIM
tokens.
- **Postmark, SendGrid, Mailgun, Gmail:** accounts and keys only if used; keys go into Infisical
under the env names the provider rows reference.
- **Keys:** `QQQ_EMAIL_UNSUBSCRIBE_KEY` and `QQQ_EMAIL_BODY_KEY` per deployment (Infisical, seeded
write-only like other app keys).