---
url: "https://vueda.dev/v3/guides/vdq-email-anymail.md"
title: "Send Email from VDQ with Anymail"
---

# Send Email from VDQ with Anymail

This guide sets up email delivery for the [VDQ (VUEDA Dispatch Queue)](/v3/reference/glossary.md#vdq-vueda-dispatch-queue) through Anymail. It covers the backend settings, the provider's tracking webhook, attachments, and how provider events set each [Queue Item](/v3/reference/glossary.md#queue-item)'s state.

[VDQ and Background Work Model](/v3/core-concepts/vdq-and-background-work.md) describes the queue item lifecycle and its known gaps. [Set Up and Operate the VUEDA Dispatch Queue (VDQ)](/v3/guides/vdq-actions.md) covers installing and routing VDQ, creating senders and receivers, running the worker, and retrying, cancelling, and resending items.

## Before You Begin

- VDQ is installed, its URLs are included, and a Celery worker is running, as [Set Up and Operate the VUEDA Dispatch Queue (VDQ)](/v3/guides/vdq-actions.md) describes.
- You have an account with an email service provider (ESP) that Anymail supports, and a verified sending domain. Your provider's documentation covers the DNS records (SPF, DKIM) for that domain.

## Select the Email Backend

[core.default\_settings.get\_defaults](/v3/reference/api/py/vueda.core.default_settings.md#get_defaults) reads the `EMAIL_BACKEND` config key and puts it in Django's [Django: EMAIL\_BACKEND](https://docs.djangoproject.com/en/6.1/ref/settings/#std-setting-EMAIL_BACKEND) setting. The key's default is Django's console backend, which prints each message instead of sending it.

For Mailgun, set these keys in `config.local.toml`, or as environment variables, which [TomlEnv](/v3/reference/api/py/vueda.core.config/TomlEnv.md) reads first:

```toml
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
ANYMAIL_MAILGUN_API_KEY = "<Mailgun API key>"
ANYMAIL_MAILGUN_SENDER_DOMAIN = "mg.example.com"
ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY = "<Mailgun webhook signing key>"
ANYMAIL_WEBHOOK_SECRET = "<random user>:<random password>"
```

When `EMAIL_BACKEND` names the Mailgun backend, `get_defaults` requires the other four keys, and settings fail to load if one is missing. `get_defaults` also sets `ANYMAIL_MAILGUN_API_URL`, which defaults to `https://api.mailgun.net/v3`. Set that key to use another Mailgun API host.

For another ESP, set `EMAIL_BACKEND` to that ESP's Anymail backend. `get_defaults` sets no provider settings for it, so add the ESP's `ANYMAIL_*` settings and `ANYMAIL_WEBHOOK_SECRET` in your settings module, after the `get_defaults` line. VUEDA installs `django-anymail` with the `mailgun`, `sendgrid`, and `sparkpost` extras. Add the extra for any other ESP to your project's dependencies.

[Configuration Surface and Defaults](/v3/core-concepts/configuration-surface-and-defaults.md) describes the `use_mailers` option, which puts the backend in Django 6.1's `MAILERS` setting instead.

### ESPs without metadata support

By default, VDQ puts each queue item's key in the email's Anymail `metadata`. A tracking event can then find its item before VDQ stores the message ID. [Matching a status update to its queue item](/v3/core-concepts/vdq-and-background-work.md#matching-a-status-update-to-its-queue-item) describes this fallback. On an ESP that accepts no metadata, Anymail raises `AnymailUnsupportedFeature` for every email. `send_email` then logs the exception at info level, builds the email again without the key, and sends it. The ESP receives each email once, and its tracking events match by message ID only.

To skip the second build and the log line, select the no-op strategy in your settings module:

```python
VDQ_EMAIL_TRACKING_STRATEGY = "vueda.vdq.tracking.EmailTrackingStrategy"
```

This setting changes nothing else about the send. If the ESP can carry the key another way, such as in a tag, write a subclass of [EmailTrackingStrategy](/v3/reference/api/py/vueda.vdq.tracking/EmailTrackingStrategy.md) instead.

With Anymail's `IGNORE_UNSUPPORTED_FEATURES` setting on, Anymail drops the metadata without raising. The default strategy then sends each email on the first attempt and writes no log line.

## Connect the Tracking Webhook

The provider reports delivery results to Anymail's tracking webhook. [vdq.urls](/v3/reference/api/py/vueda.vdq.urls.md) mounts Anymail's URLs at `vueda.vdq/anymail/`, below the prefix where you include the VDQ URLs. With VDQ included under `routes/`, the prefix that the scaffolded project uses, the Mailgun tracking URL is:

```text
https://<user>:<password>@app.example.com/routes/vueda.vdq/anymail/mailgun/tracking/
```

For another ESP, replace `mailgun` with its Anymail name, such as `sendgrid` or `sparkpost`.

1. Put the two halves of `ANYMAIL_WEBHOOK_SECRET` in the URL as the user and password. Anymail returns `400` for a webhook request whose HTTP basic auth credentials do not match.
2. Register the URL in the provider's dashboard for delivery, bounce, and failure events.
3. For Mailgun, copy the webhook signing key from the same dashboard into `ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY`. Anymail checks each event's signature with it.

## Send an Email

Call [vdq.schedulers.add\_email](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email) with a [Sender](/v3/reference/api/py/vueda.vdq.models/Sender.md) and one or more [Receiver](/v3/reference/api/py/vueda.vdq.models/Receiver.md) rows:

```python
from vueda.vdq.schedulers import add_email

queue_items = add_email(
    sender=support,
    to=[customer],
    subject="Your order has shipped",
    text="Your order is on its way.",
    html="<p>Your order is on its way.</p>",
    reply_to=[orders_desk],
    origin=order,
)
```

`add_email` returns the queue items it created:

- It creates one queue item for each receiver in [`to`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-to), [`cc`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-cc), and [`bcc`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-bcc). Each item sends one message, addressed to its own receiver only.
- The message carries a `Reply-To` header with the email addresses of the [`reply_to`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-reply_to) receivers.
- The sender, every receiver, and every `reply_to` receiver must have an email address. Otherwise `add_email` raises `ValueError`, and its transaction rolls back every row it created.
- [`origin`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-origin) links each queue item to the record that caused the email. A model that subclasses [QueueItemOrigin](/v3/reference/api/py/vueda.vdq.models/QueueItemOrigin.md) can reach its queue items through a generic relation.
- `add_email` publishes the send task when the surrounding transaction commits.

Receivers in `cc` and `bcc` each get their own message with no `Cc` header, so they do not see each other's addresses.

To create the queue items without sending them yet, call [vdq.schedulers.add\_abstract\_email](/v3/reference/api/py/vueda.vdq.schedulers.md#add_abstract_email) with the same arguments. Call [vdq.schedulers.schedule\_queue\_item](/v3/reference/api/py/vueda.vdq.schedulers.md#schedule_queue_item) on each returned item when it is ready.

## Attach Files

Pass [`attachments`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_email-param-attachments) as a mapping from file name to a mapping with these keys:

- `mimetype`: required, such as `application/pdf`.
- `file`: required, a file object opened in binary mode. `add_email` reads it during the call.
- [`content_disposition_is_inline`](/v3/reference/api/py/vueda.vdq.models/AbstractQueueItemAttachment.md#content_disposition_is_inline) and [`content_id_string`](/v3/reference/api/py/vueda.vdq.models/AbstractQueueItemAttachment.md#content_id_string): optional. Set both to embed an image in the HTML body.

```python
with open("logo.png", "rb") as logo, open("invoice.pdf", "rb") as invoice:
    add_email(
        sender=support,
        to=[customer],
        subject="Your invoice",
        text="Your invoice is attached.",
        html='<p><img src="cid:logo"> Your invoice is attached.</p>',
        attachments={
            "logo.png": {
                "mimetype": "image/png",
                "file": logo,
                "content_disposition_is_inline": True,
                "content_id_string": "logo",
            },
            "invoice.pdf": {"mimetype": "application/pdf", "file": invoice},
        },
    )
```

[vdq.handlers.send\_email](/v3/reference/api/py/vueda.vdq.handlers.md#send_email) attaches an inline image under a content ID that Anymail generates. It rewrites each `cid:<content_id_string>` reference in the HTML to that ID.

`add_email` saves each file once, under `email_queue_item_attachments/` in the default storage ([Django: STORAGES](https://docs.djangoproject.com/en/6.1/ref/settings/#std-setting-STORAGES)). All queue items from the call share those attachment rows. To reuse attachments from an earlier call, pass a list of saved [AnyMailQueueItemAttachment](/v3/reference/api/py/vueda.vdq.models/AnyMailQueueItemAttachment.md) rows instead of a mapping.

### Serve attachment links

The queue item API lists each attachment with a download URL built from the `VDQ_URL` setting. `get_defaults` does not set it. Set it in your settings module to the path of the VDQ URLs:

```python
VDQ_URL = "/routes/vueda.vdq"
```

Links then point at the [vueda.vdq\_attachments\_retrieve](/v3/reference/api/rest/vueda.vdq/vueda.vdq_attachments_retrieve.md) endpoint. Any signed-in user can download an attachment by its ID. [VDQ and Background Work Model](/v3/core-concepts/vdq-and-background-work.md) describes this gap.

### Delete attachment files after sending

To delete attachment files once they are sent, set `VDQ_MAX_FILES_AGE_IN_SECONDS = 0` in your settings module. When a queue item reaches a [Done State](/v3/reference/glossary.md#done-state), VDQ deletes the file of each attachment whose queue items are all done. The attachment row stays. With any other value, or with the setting unset, VDQ keeps the files.

Resending an item whose attachment files were deleted fails, because the send reads each file. The new item moves to `errored`.

## Provider Errors and Retries

`send_email` sends through Anymail and updates the queue item from the result:

| Result                                                          | Queue item                                                                                                                                 |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Accepted with Anymail status `sent` or `queued`                 | Stores the provider's message ID in [`message_id`](/v3/reference/api/py/vueda.vdq.models/AnyMailQueueItem.md#message_id) and moves to `awaiting`. |
| Accepted with any other status                                  | Records the status in [`result`](/v3/reference/api/py/vueda.vdq.models/QueueItem.md#result) and moves to `errored`.                               |
| Provider API error with HTTP status 5xx, `408`, `423`, or `429` | Raises [AnymailTransientError](/v3/reference/api/py/vueda.vdq.exceptions/AnymailTransientError.md), and the task retries. The item waits in `delayed`.                      |
| Any other error                                                 | Appends the traceback to `result` and moves to `errored`.                                                                                  |

A tracking event can reach the item before `send_email` records the result. If the event has already moved the item out of `sending`, `send_email` stores the message ID and leaves the state and `result` unchanged.

The task class [BaseTask](/v3/reference/api/py/vueda.vdq.tasks/BaseTask.md) sets the retry schedule. It allows up to 12 retries ([Celery: Task.max\_retries](https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#id0)) with exponential backoff from 16 seconds ([Celery: Task.retry\_backoff](https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#Task.retry_backoff)), without jitter. When the retries run out, the item moves to `errored`.

## Provider Events and Queue Item States

Anymail sends each tracking event to [vdq.handlers.handle\_bounce](/v3/reference/api/py/vueda.vdq.handlers.md#handle_bounce). It finds the queue item by the event's message ID. When no item stores that ID, it asks the email tracking strategy for the item. The default strategy reads the queue item key from the event's `metadata`. [Matching a status update to its queue item](/v3/core-concepts/vdq-and-background-work.md#matching-a-status-update-to-its-queue-item) describes this fallback. The event then changes the item:

| Event type            | Effect                                                                                        |
| --------------------- | --------------------------------------------------------------------------------------------- |
| `delivered`           | Clears `result` and moves the item to `succeeded`.                                            |
| `bounced`, `rejected` | Records the provider's reject reason in `result` and moves the item to `errored`.             |
| `failed`              | Records `Email Failed.` in `result` and moves the item to `errored`.                          |
| `deferred`            | Writes a note to `result` that the provider will retry. Moves a `sending` item to `awaiting`. |
| `queued`, `sent`      | Moves a `sending` item to `awaiting`. No change for an item in any other state.               |
| Any other type        | Logged at info level. No change.                                                              |

`handle_bounce` logs an event that finds no queue item as a warning and skips it. [VDQ and Background Work Model](/v3/core-concepts/vdq-and-background-work.md) describes what happens when an event arrives for an item that is in neither `sending` nor `awaiting`.

## Verify the Setup

1. Send a request to the tracking URL without credentials:

    ```sh
    curl -i -X POST https://app.example.com/routes/vueda.vdq/anymail/mailgun/tracking/
    ```

    The response is `400`, because the basic auth credentials are missing.

2. Send a test event from the provider's dashboard. The server log shows `Tracking event <type> for unknown message_id=<id>`, because the test event does not name a queue item.
3. Send an email with `add_email` to an address you control.
4. Sign in as a user whose groups grant `vueda_vdq.read_queueitem`, and fetch the item by its ID from the queue item endpoint ([vueda.vdq\_queueitem\_list](/v3/reference/api/rest/vueda.vdq/vueda.vdq_queueitem_list.md)). Its `workflow_state_code` is `awaiting` after the send, and `succeeded` after the provider reports delivery.
