Transactional Email
Available with no license, in the Development posture. A license adds the MCP and A2A planes, production rights and support — not this feature.
The DVARA Flightdeck pod is the only place that sends outbound email — every other pod hands the request off to the Flightdeck pod, which renders the template and dispatches it. This page covers the pieces an operator needs to reason about: picking a transport, what survives a transient failure, what fails fast, and where to look when something goes missing.
Producers
These flows publish emails (rendered + delivered by Flightdeck).
The built-in-auth producers are the universal set — they fire on every install (self-managed, managed, and DVARA Cloud):
| Producer | Trigger |
|---|---|
| Built-in auth — the invitation email | Owner creates a user via POST /users |
| Built-in auth — the password-reset email | User submits /forgot-password |
| Built-in auth — the email-verification email | First-run /setup and /register flows |
The remaining producers exist only when the install runs in SaaS mode — the managed DVARA Cloud offering. Self-managed and managed-hosting installs never emit these:
| Producer | Trigger |
|---|---|
| The signup / welcome email | /signup (trial) and the paid-checkout completion |
| Threshold notifications | Workspace crosses 80% / 100% / 110% of its monthly governed-call allowance (one email per band). The unit is calls, not tokens — the emitter reads the month's request count against the tier's pooled band, which the LLM, MCP and A2A planes share |
| Chronic-abuse suspension | Workspace suspended after consecutive months over cap |
All producers go through the same publish path. A producer never blocks on transport — the request is handed off synchronously, and delivery happens on a separate thread.
Transports
Pick one transport with dvara.flightdeck.email.transport:
| Value | Behavior | Use when |
|---|---|---|
log (default) | Logs a one-line summary + every CTA link on its own line at INFO; full HTML body at DEBUG | Dev, CI, demos, first-day soft launch |
smtp | Delivers over SMTP using the standard spring.mail.* properties. Requires spring.mail.host — see the note below | Self-managed deployment with corporate SMTP |
resend | POSTs to resend.com's transactional API | SaaS / managed deployments wanting deliverability + bounce handling |
:::caution transport=smtp requires spring.mail.host, and now refuses to start without it
Setting transport=smtp is not sufficient on its own. Spring registers the mail sender only when
spring.mail.host is set, and without it Flightdeck fails to start, naming the missing
property.
That is deliberate. It previously fell back to the log transport and reported healthy — so
invitations and password resets went to stdout and nobody could accept an invite or reset a
password, with nothing failing to say so. Refusing at deploy time is louder than discovering it from
a user who cannot log in.
Upgrading? If you run transport=smtp without spring.mail.host today, that instance is
already delivering nothing, and it will now stop starting. Set the host, or choose resend or log
deliberately.
:::
Finding the invitation / password-reset link under transport=log
Every send under the log transport prints a two-line block to flightdeck's stdout: the summary first, then each actionable URL on its own link: … line. Grep for link::
docker compose logs dvara-flightdeck | grep -B1 'link:'
Sample output:
Email (log transport) | to=alice@example.com subject=You've been invited to DVARA htmlChars=4690
link: http://localhost:8090/register?token=ef726772-b312-4c40-b4a9-3b1ec7adb33f
Copy the URL into your browser to complete onboarding. Same pattern for password-reset (/reset-password?token=…) and email verification — anything with a ?token=… parameter is surfaced. Non-actionable links in the template (brand footer, docs, mailto: support) are deliberately not echoed.
The rendered HTML body is not logged at INFO — it's 4–5 KB of rendered HTML per send and would dominate the log stream. If you need to inspect the full body, raise the email transport's log level to DEBUG.
transport=resend — production checklist
- Sign up at resend.com and create an API key. Scope Full access (Flightdeck needs
POST /emailsandGET /domains). - Verify your sender domain at resend.com/domains. Configure the DKIM + Return-Path records, wait for status to flip to verified. The sandbox sender
onboarding@resend.devskips verification but customer-facing copy readsfrom: onboarding@resend.dev— fine for soft launch, not for marketing-clean GA. - Set:
DVARA_FLIGHTDECK_EMAIL_TRANSPORT=resendDVARA_FLIGHTDECK_EMAIL_FROM=noreply@yourdomain.comDVARA_FLIGHTDECK_EMAIL_RESEND_API_KEY=re_…
- By default (
dvara.flightdeck.email.resend.verify-domain-at-startup=true) Flightdeck callsGET /domainsat boot and refuses to start on a production-class profile if the sender domain isn't verified at the Resend end. The sandbox sender skips this check. Disable on air-gapped / no-egress environments by settingresend.verify-domain-at-startup=false.
Common transport vars
| Property | Env Var | Default | Description |
|---|---|---|---|
dvara.flightdeck.email.from | DVARA_FLIGHTDECK_EMAIL_FROM | noreply@dvarahq.com | Sender address on every outbound |
dvara.flightdeck.email.transport | DVARA_FLIGHTDECK_EMAIL_TRANSPORT | log | log, smtp, or resend |
dvara.flightdeck.email.public-endpoint-url | DVARA_FLIGHTDECK_EMAIL_PUBLIC_ENDPOINT_URL | https://api.dvarahq.com/v1 | Data-plane URL shown in welcome + check-email pages |
dvara.flightdeck.email.flightdeck-url | DVARA_FLIGHTDECK_EMAIL_FLIGHTDECK_URL | https://platform.dvarahq.com | Flightdeck base URL for welcome + reset CTAs |
dvara.flightdeck.email.docs-url | DVARA_FLIGHTDECK_EMAIL_DOCS_URL | https://dvarahq.com/docs | Docs link in welcome email |
dvara.flightdeck.email.resend-api-key | DVARA_FLIGHTDECK_EMAIL_RESEND_API_KEY | (empty) | Required when transport=resend |
spring.mail.host | SPRING_MAIL_HOST | (empty) | Required when transport=smtp — Flightdeck refuses to start without it |
spring.mail.port | SPRING_MAIL_PORT | 25 | SMTP port |
spring.mail.username | SPRING_MAIL_USERNAME | (empty) | SMTP username, when the server requires auth |
spring.mail.password | SPRING_MAIL_PASSWORD | (empty) | SMTP password |
Durability layer
Every send goes through a durable delivery record in the DVARA control plane store, not directly out the transport. This buys four properties operators care about:
- Idempotency. A second publish with the same idempotency key within the configured TTL is a no-op. A retried inbound webhook can't double-mail the customer.
- Retry with exponential backoff. Transport failures classified as transient (timeouts, 5xx, throttling) re-enter the queue. Permanent failures (4xx, render errors) DLQ immediately.
- Dead-letter queue. Exhausted retries are marked dead-lettered for operator review. Dead-lettered records are retained for
dlq-retention-days(default 30) and reaped by a nightly cron. - Survive a pod restart. A send in flight when Flightdeck restarts is picked up by the retry sweeper on the next tick — no in-memory state to lose.
Set dvara.flightdeck.email.delivery.enabled=false to fall back to fire-and-forget (no DB row, no idempotency, no retry, no DLQ). Useful for tests that don't want a Postgres dep — not recommended for any production-class install.
Retry schedule
Default schedule gives 5 attempts with exponential backoff, total ~5m 30s before DLQ:
attempt 1 → 0s (synchronous, in the listener)
attempt 2 → +30s (initial-backoff-ms)
attempt 3 → +60s (initial × multiplier^1)
attempt 4 → +120s (initial × multiplier^2, capped at max-backoff-ms)
attempt 5 → +120s (cap holds)
→ DEAD_LETTERED
Tune via delivery.initial-backoff-ms, delivery.max-backoff-ms, delivery.backoff-multiplier, and delivery.max-attempts. The retry sweeper polls every retry-sweep-interval-ms (default 30s) and processes up to retry-sweep-batch-size (default 100) due rows per tick — both are throughput dials for very high mail volumes.
Delivery knobs
All ten ship with sensible defaults. Tune only when you have a specific reason — pinning Resend rate-limit pressure, matching SES SLA, aggressive vs gentle backoff.
| Property | Env Var | Default | Description |
|---|---|---|---|
dvara.flightdeck.email.delivery.enabled | DVARA_FLIGHTDECK_EMAIL_DELIVERY_ENABLED | true | Master switch for the durability layer |
dvara.flightdeck.email.delivery.max-attempts | DVARA_FLIGHTDECK_EMAIL_DELIVERY_MAX_ATTEMPTS | 5 | Sync attempt 1 + 4 async retries before DLQ |
dvara.flightdeck.email.delivery.initial-backoff-ms | DVARA_FLIGHTDECK_EMAIL_DELIVERY_INITIAL_BACKOFF_MS | 30000 | Backoff before attempt 2 |
dvara.flightdeck.email.delivery.max-backoff-ms | DVARA_FLIGHTDECK_EMAIL_DELIVERY_MAX_BACKOFF_MS | 120000 | Ceiling on any single retry's backoff |
dvara.flightdeck.email.delivery.backoff-multiplier | DVARA_FLIGHTDECK_EMAIL_DELIVERY_BACKOFF_MULTIPLIER | 2.0 | Exponential factor — delay(n) = min(initial × multiplier^(n-2), max) for n ≥ 2 |
dvara.flightdeck.email.delivery.retry-sweep-interval-ms | DVARA_FLIGHTDECK_EMAIL_DELIVERY_RETRY_SWEEP_INTERVAL_MS | 30000 | How often the retry sweeper polls |
dvara.flightdeck.email.delivery.retry-sweep-batch-size | DVARA_FLIGHTDECK_EMAIL_DELIVERY_RETRY_SWEEP_BATCH_SIZE | 100 | Max rows processed per sweeper tick |
dvara.flightdeck.email.delivery.idempotency-ttl-minutes | DVARA_FLIGHTDECK_EMAIL_DELIVERY_IDEMPOTENCY_TTL_MINUTES | 60 | Dedupe window — a second publish of the same idempotencyKey inside the window is a no-op |
dvara.flightdeck.email.delivery.dlq-retention-days | DVARA_FLIGHTDECK_EMAIL_DELIVERY_DLQ_RETENTION_DAYS | 30 | How long SENT + DEAD_LETTERED rows are kept |
dvara.flightdeck.email.delivery.cleanup-cron | DVARA_FLIGHTDECK_EMAIL_DELIVERY_CLEANUP_CRON | 0 0 3 * * * | Nightly DLQ + idempotency purge (default 03:00 UTC). PENDING_RETRY rows are never touched. |
Idempotency-key collision caveat: the dedupe window is idempotency-ttl-minutes, but DLQ rows are retained for dlq-retention-days. A producer that reuses a deterministic UUID across the retention boundary will silently lose the audit row on PK collision. Producers reusing deterministic UUIDs across that boundary should regenerate.
Observability
Every send + retry + DLQ transition emits both an audit event and a Prometheus counter increment — no extra wiring.
Audit events
| Event type | When |
|---|---|
EMAIL_SENT | Transport accepted the message (sync attempt 1 or any retry). Read delivered — this event alone does not mean the mail was sent |
EMAIL_FAILED | Send failed — payload carries terminal (true = dead-lettered, false = will retry) and last_error |
EMAIL_RETRIED | A retry attempt is being made — fires before the transport call so the timeline reflects intent even if the transport throws unexpectedly |
Every audit event carries template, transport, recipient_hash, attempt_count, and (when scoped) workspace_id. EMAIL_SENT additionally carries delivered — see the warning below.
:::warning EMAIL_SENT alone is not proof of delivery — read delivered
A send counts as successful when the transport does not throw, and the log transport prints to
stdout and never throws. So an EMAIL_SENT event is written for a message that was only logged.
transport defaults to log, so on a default install every invitation and password reset
produces one.
EMAIL_SENT therefore carries delivered: true only when the message left the process.
dvara_emails_sent_total carries the same as a tag.
Query delivered="true", not result="SUCCESS" — the latter counts console prints. If you have
built a report or an alert on EMAIL_SENT or on result=SUCCESS, it has been counting logged mail
as delivered; add the delivered filter.
:::
The recipient address is never stored raw — only a recipient hash, so compliance teams can answer "did we attempt to email this person?" without the address landing in the audit trail. The rendered HTML body is not stored either — audit retention is for compliance, not for replaying customer mail.
Prometheus metrics
dvara_emails_sent_total{template, transport, result}
dvara_emails_retried_total{template, attempt}
result is SUCCESS, TRANSIENT, PERMANENT, or MAX_ATTEMPTS_EXCEEDED. The two counters cover both the headline delivery rate and the retry-pressure signal that tells you when to widen the backoff or cap.
Useful dashboard queries:
# Send-success rate (golden signal)
rate(dvara_emails_sent_total{result="SUCCESS"}[5m])
/ rate(dvara_emails_sent_total[5m])
# DLQ pressure — non-zero means customers are missing email
rate(dvara_emails_sent_total{result="MAX_ATTEMPTS_EXCEEDED"}[1h])
# Retry-storm signal — sustained increase = transport degraded
rate(dvara_emails_retried_total[5m])
Recovery and the dead-letter queue
For day-to-day onboarding under transport=log, prefer the log-grep path above — it surfaces the registration link directly with no other steps.
Once delivery is on a real transport, the audit trail is the operator-facing record of what was attempted and what failed. Each send, retry, and dead-letter transition emits an audit event (see Audit events below), so "did this customer's invitation actually deliver three weeks ago?" is answered by filtering the audit stream — in the DVARA Console under Audit → Events — by the email event types and the time range.
When retries are exhausted, the delivery is dead-lettered and stops retrying on its own. There is no self-service replay endpoint and no public API for re-driving a dead-lettered email by design — re-attempting a failed customer email is an operator decision, not a self-service one. The practical recovery path is to fix the underlying cause (correct the transport configuration, verify the sender domain, restore egress) and then re-trigger the originating action — re-send the invitation from /users/new, have the user re-request a password reset, and so on. Each re-trigger publishes a fresh delivery that runs the full retry schedule again.
Choosing a transport
| Profile | Recommended |
|---|---|
| Local dev, CI, smoke tests | log |
| Self-hosted with corporate SMTP | smtp |
| SaaS / managed deployment | resend (or smtp if you front your own SES / SendGrid) |
| Air-gapped / no-egress | log with manual operator escalation, or smtp to an internal relay |
The default log transport is deliberately safe — a fresh install sends nothing to anyone until an operator explicitly picks smtp or resend. There is no built-in transport that calls out to a third party on first boot.
Migration note
Properties under dvara.flightdeck.email.resend.retry-* (retry-max-attempts, retry-initial-backoff-ms, retry-max-backoff-ms) on the legacy Resend transport are defunct as of 1.0.0-GA — retry now lives at the listener level via dvara.flightdeck.email.delivery.*. The legacy names are kept for one release with a deprecation WARN; remove them from your .env and use the delivery.* namespace.