---
title: "Email this dashboard's report now (Snowflake native app only)."
method: POST
path: "/v1/dashboards/{id}/email"
tags: ["dashboards"]
---

# Email this dashboard's report now (Snowflake native app only).

`POST /v1/dashboards/{id}/email`

## Request

- Base URL: `http://localhost:8080`
- URL: `http://localhost:8080/v1/dashboards/{id}/email`
- Auth: one of:
  - API key in cookie `dvt_session`
  - HTTP bearer

"Email me this report now" (DVT-4201). Snowflake-native only — 404 outside
snowflake mode. Renders the dashboard's KPI/stat tiles and tables inline as HTML
and each chart panel as an inline base64 PNG (DVT-4264) with a "View in dvt →"
link; no download links. By default (no attachments) it is sent
via `CALL SYSTEM$SEND_EMAIL(...)` executed on the CALLER's own Snowflake
session (the same caller's-rights path as `POST /v1/data/query`), through the
consumer's own NOTIFICATION INTEGRATION (see the native-app README's "Enable
email reports" step). If the consumer admin opted into the SMTP relay (native
app 1.1.1+, DVT-5057), it is instead sent as multipart MIME through the
consumer's own mail host, with charts as inline images and an `.xlsx`
attachment per table panel (up to 50 rows; every column of the panel's query result, declared columns first, as Export). On a dashboard with a pinned query role, the email CALLs
(`smtp_status()`, then `get_email_integration()` + `SYSTEM$SEND_EMAIL`, or on the
relay `smtp_relay_send`) run under that pinned role instead, which therefore needs the dvt viewer application role (and,
pending measurement, `CALLER USAGE` on the integration — DVT-4345). Requires
`data:query` AND `data:export`, plus `dashboard:read` on this dashboard.

Panels that fail to load render as a placeholder with a generic reason (no raw
Snowflake error text is ever included in the email body). At most 12 data-bound
panels are queried per email — the rest render as "not included" placeholders
(counted in `panelsOmitted`). Tables render up to 50 rows inline, with a "N more
rows" link for the remainder. The whole request runs under a 15s panel budget plus
a send budget (10s Snowflake, 25s SMTP relay); if no data panel loads in time, or too
little of the request window (80s, measured from arrival) is left to start the send, the
email is NOT sent and is safe to retry (504 `report-timed-out`), if every data panel fails for another reason it is NOT sent
either (502 `report-unavailable`), and a send that does not confirm in time returns
504 `email-send-unconfirmed` — check inboxes before retrying, the mail may still
arrive.

Chart images are rendered server-side by dvt-render, at most 12 per email, within a
per-email render time budget (charts that have not started rendering when it runs
out are noted in place). The dedicated per-org hourly render budget (separate
from the export/user render budget, so emailing a report can never rate-limit an
export or vice versa) is off by default on the native app (no hourly cap); dvt
can set one via `EMAIL_RENDER_RATE_LIMIT` (a service-template value, not
consumer-settable). Charts
never fail the send: a chart that cannot be rendered — including one refused
because an operator-set hourly budget is spent — is noted in place and counted in
`panelsFailed`, and one that would push the body over the 700 KB budget, that
sits past the per-email chart cap, or that was not started because the per-email
render time budget ran out, is noted in place and counted in
`panelsOmitted`. Note that Gmail strips `data:` image URIs and shows the image's
alt text instead; Apple Mail, Outlook desktop and iOS Mail render them inline.

`params` carries the sender's ACTIVE filter state (DVT-4468), so the mail matches
the filtered screen rather than the dashboard's default view: every panel that
declares a matching key in its `data.params` is queried with the supplied value,
the chart renders receive the same values so the pictures and the tables agree,
and the document carries a "Filtered: Region = West" line naming what was applied
(also recorded on the `dashboard.email` audit row as `params_applied`). Values are
BOUND, never interpolated; an undeclared key is a 422 that names it. Omitting
`params` reproduces the pre-DVT-4468 report exactly. A stored SCHEDULE carries no
filter state, so an unattended fire still sends the default view.

**Async mode (DVT-5096).** Send BOTH `Prefer: respond-async` and an
`Idempotency-Key` header and the request is validated exactly as above, then a
background run is recorded and the call answers `202` within about a second with
an `EmailRun` (status `queued`) instead of waiting for the send. Poll
`GET /v1/dashboards/{id}/email-runs/{runId}` for the result. The run's clock is
anchored to request arrival and it must start within the life of the caller's
Snowflake session; a session too short-lived to start the send is refused `503`
`email-async-unavailable` with nothing recorded (retry the same request with the
same `Idempotency-Key`). Replaying the same `Idempotency-Key` returns `200` with
the SAME run and never sends a second email — even if the body differs, so mint a
new key for every distinct send; the same key for another dashboard is `409`. A
user may have at most 3 background emails running at once (`429`). A run whose
worker is lost is reported `unconfirmed` (the mail may have been handed off —
check inboxes) or `not_attempted` (it never started — safe to retry); it is
never reported `failed` unless the send itself definitively failed. With
neither header the request is the synchronous call described above, unchanged;
with only one of them it is `400`.

## Headers

- `Prefer` string
- `Idempotency-Key` string

## Request body

- EmailDashboardRequest: Body for POST /v1/dashboards/{id}/email.
  - `recipients` string[], required: Recipient email addresses (1-50). Each must be a bare address (no display name). On the default Snowflake transport, Snowflake additionally requires every recipient be a verified user of the consumer's own account (`IS_EMAIL_VERIFIED`) — an unverified recipient fails the whole send, not just that one address; on an install whose admin opted into the SMTP relay (native app 1.1.1+), every recipient must instead be on the admin's relay allowlist.
  - `pageId` string, nullable: When set, emails only this page's panels (with the page title as the subject/heading). `null` or absent emails every non-hidden page, in order, each under its own section heading (hidden pages are excluded, ADR-0036 §1). If every page is hidden and there are no top-level panels, the request is refused with 400; name a page explicitly.
  - `subject` string: Optional subject override, truncated to 256 runes. Defaults to "dvt report: <dashboard title>" (or "dvt report: <dashboard title> · <page title>" when `pageId` is set).
  - `params` object, nullable: The report's active filter/param state — the already-resolved values the sender's screen is querying with, e.g. `{"region": "West"}`. Every panel that declares a matching key in its `data.params` is queried with the supplied value instead of its authored default, the same values are used to render the charts (so the pictures and the tables agree), and the mail carries a "Filtered: Region = West" line naming them. Every key must be declared by some panel's `data.params` on the page being emailed (`pageId` narrows that scope) — an undeclared key is a 422 naming it, never a silent drop. Values are always bound, never interpolated. A key a given panel does not declare is not added to that panel. Omit entirely to email the report's own default view; `null`, like an absent or empty map, is the same unfiltered send. The whole map is capped at 15 KiB encoded (422 past it). That is not a storage limit: the same map is carried to the chart renders on the render URL, whose carriers drop an oversized value — so a map that cannot reach them is refused up front rather than producing filtered tables under unfiltered charts. This map states ONE value per param for the WHOLE report, so it cannot express a selection that applies to only some of the emailed panels (a per-panel `targets` list, or a page-scoped selection on an all-pages send). dvt's own UI does not send such a key and tells the user it was not carried; a client building this payload itself should do the same rather than send a value that would narrow panels the viewer sees in full. Filters on SCHEDULED sends are separate: a schedule stores no filter state, so an unattended fire still sends the default view.

## Response `200`

Async mode only: the `Idempotency-Key` was already used for this dashboard. Returns the existing run; no new send was started.

- object
  - `data` EmailRun, required: One async attended "Email now" run (DVT-5096): the 202/200 body of async `POST /v1/dashboards/{id}/email` and the body of `GET /v1/dashboards/{id}/email-runs/{runId}`. `queued` and `rendering` are open; the rest are terminal.
    - `runId` string, uuid, required
    - `dashboardId` string, uuid, required
    - `status` 'queued' | 'rendering' | 'sent' | 'unconfirmed' | 'failed' | 'not_attempted', required: `queued` and `rendering` are open. `sent` = the mail was handed off. `failed` = the send did not complete; some recipients may already have received it (reason `relay_partial`) — check before retrying. `unconfirmed` = may have been sent — check inboxes before retrying. `not_attempted` = nothing was sent; safe to retry with a NEW Idempotency-Key.
    - `outcome` 'sent' | 'failed' | 'unconfirmed' | 'not-sent-timeout' | 'not-sent-failed' | 'not-sent-too-large': The send outcome (the `dashboard.email` audit spelling); absent while open.
    - `panelsFailed` integer, required
    - `panelsOmitted` integer, required
    - `reason` string: Short machine code (a string, not an enum) for why a run did not send cleanly; never customer data. Set by the sweep of a lost worker (`worker_lost_before_start` for a run that never started, `worker_lost` for one that had) or the worker (`window_spent`, `mark_rendering_failed`, `worker_panic`, `no_outcome`, `unknown_outcome`), otherwise one of the send-failure codes (for example `relay_rejected`, `relay_partial`, `send_unconfirmed`, `over_budget`).
    - `createdAt` string, date-time, required
    - `finishedAt` string, date-time
  - `meta` Meta, required: Response metadata.
    - `requestId` string, required: Correlates with the X-Request-Id header.
    - `total` integer: Item count, present on collection responses.
    - `nextCursor` string, nullable: Opaque keyset-pagination cursor, present on paginated responses (e.g. GET /v1/dashboards) when more rows exist beyond this page. Pass it back as the `cursor` query param to fetch the next page; absent or null on the last page.
    - `roleResolveDegraded` boolean: Snowflake mode only (DVT-2260), on GET /v1/dashboards: present and true when the caller's live Snowflake role resolution failed or was unavailable — role-shared dashboards may be missing from this page. Omitted (not merely false) whenever resolution succeeded or the role-share arm does not apply (off snowflake mode, an org admin/owner, or off the ingress listener).

## Other responses

- `202`: Synchronous mode (no async headers): email accepted and sent (`EmailDashboardResponse`). Async mode: run accepted and queued (`EmailRun`); poll the run for the result.
- `400`: Malformed request, or the SEND_EMAIL call itself failed (type `email-send-failed`, detail carries Snowflake's message verbatim). In async mode also: only one of `Prefer: respond-async` / `Idempotency-Key` was sent (they must come together), or the `Idempotency-Key` is not 1-200 visible ASCII characters.
- `401`: Missing, invalid, or expired session.
- `403`: The caller's role does not permit this action.
- `404`: Resource does not exist.
- `409`: Email is not usable for this app: either it is not configured (`get_email_integration()` returned NULL, or this install is not a packaged native app), or the configured integration is not authorized (missing `GRANT USAGE` and/or `GRANT CALLER USAGE` on the NOTIFICATION INTEGRATION to the application). When the app is opted in to the SMTP relay but the relay is not ready (its send procedure is unbound or reports NOT_CONFIGURED) the type is `https://docs.dvt.dev/errors/email-relay-not-ready` and `suggestion` says to re-bind the app's `SMTP_EXTERNAL_ACCESS` and `SMTP_CREDENTIALS` references — it never falls back to SYSTEM$SEND_EMAIL (DVT-5057). In async mode a `409` of type `https://docs.dvt.dev/errors/idempotency-key-reused` means the `Idempotency-Key` was already used for a different dashboard, or belongs to a run that no longer exists; mint a new key (DVT-5096).
- `413`: The rendered report exceeds the email byte budget even with every table omitted (`https://docs.dvt.dev/errors/email-too-large` — also returned when Snowflake itself rejects the CALL as too large), or the request body exceeds 64 KiB (`https://docs.dvt.dev/errors/payload-too-large`); email a single page or trim text panels. On the SMTP relay transport the MIME message exceeded the relay's 5 MiB cap (`https://docs.dvt.dev/errors/email-relay`, with a `suggestion`).
- `422`: `params` named a param no panel on the emailed page declares in its `data.params` (the Problem `detail` NAMES every offending key, never a value), carried a value that cannot be bound to a query (a nested object or array), had more than 50 entries, or exceeded 15 KiB encoded. The body is well-formed — it is the content this dashboard cannot honour — so it is 422 rather than 400. Nothing is queried or sent (DVT-4468). On the SMTP relay transport a 422 is also returned (`https://docs.dvt.dev/errors/email-relay`, with a `suggestion`) when a recipient address is not supported by the relay, the relay allowlist is empty or does not include a recipient, there are more than 50 recipients, or the relay refuses the message headers (sender address). Nothing was sent (DVT-5057).
- `429`: SMTP relay transport only: the relay's send limit was reached (`https://docs.dvt.dev/errors/email-relay`, relay outcome `RATE_LIMITED`; `suggestion` says to wait and retry). Nothing was sent. The default SYSTEM$SEND_EMAIL transport never returns 429 for this reason (DVT-5057). In async mode a `429` of type `https://docs.dvt.dev/errors/rate-limited` (with `Retry-After: 30`) means too many background emails are already running for the user (3) or the server (32); nothing was recorded (DVT-5096).
- `502`: Either every data-bound panel failed to load for a non-timeout reason (broken query, no warehouse, unresolved data source, ...), so the email was NOT sent (`https://docs.dvt.dev/errors/report-unavailable`; the Problem `detail` carries the generic per-panel reason, never raw Snowflake error text) — or the analytics engine could not be reached during the integration lookup, a panel query, or the send itself (`https://docs.dvt.dev/errors/engine-unavailable`). One `report-unavailable` body explicitly denies the "the panels failed" framing: when the reason is that the caller's Snowflake session could not be opened at all, the failure is at the session layer and happened before any panel query ran, so `detail` says so rather than blaming the dashboard's queries (DVT-4345). Separately, when it is the integration lookup's own Snowflake login that Snowflake refuses — not a panel query — the Problem type is `https://docs.dvt.dev/errors/caller-connect-refused` directly: no session was established and the caller's SQL was never reached, so the remediation is a fresh dvt session rather than the engine logs (DVT-4345). On the SMTP relay transport a 502 (`https://docs.dvt.dev/errors/email-relay`, with a `suggestion`) means the customer's mail server refused or could not deliver: a login rejection (`AUTH_FAILED`), a TLS/connect/SMTP failure, or a PARTIAL send where only some recipients accepted the mail — in the PARTIAL case the `detail` states "accepted N of M recipients" and a blind retry re-sends to the recipients who already received it (DVT-5057).
- `503`: Async mode only (`https://docs.dvt.dev/errors/email-async-unavailable`): background email is not available on this install, or this request's Snowflake session is too short-lived to start the email in the background. Nothing was recorded or sent for this `Idempotency-Key`; retry the same request with the same `Idempotency-Key`, or send without `Prefer: respond-async` and `Idempotency-Key` if you never received a run id for it (DVT-5096).
- `504`: Either no data-bound panel loaded before the 15s panel budget expired, or too little of the request window (80s, measured from arrival) was left to start the send, so the email was NOT sent and is safe to retry (`https://docs.dvt.dev/errors/report-timed-out`), or the send itself did not confirm within its send budget (10s Snowflake, 25s SMTP relay) (`https://docs.dvt.dev/errors/email-send-unconfirmed`) — in the latter case the mail MAY still be delivered; check inboxes before retrying to avoid a duplicate.

---

[API](https://skmtc.dev/dvt/apis/dvt-rest-api.md) · [All operations](https://skmtc.dev/dvt/apis/dvt-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dvt/apis/dvt-rest-api/revisions/00016f504165?raw)
