> ## Documentation Index
> Fetch the complete documentation index at: https://pigeonpost-developer.27communication.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Attachments

> Find, download and send email attachments, and show inline images.

Emails can carry files: PDFs, images, spreadsheets and so on. This page explains how to:

1. [Find the attachments of an email](#1-find-the-attachments-of-an-email)
2. [Download an attachment](#2-download-an-attachment)
3. [Show inline images](#3-show-inline-images) (images inside the email body)
4. [Send an email with attachments](#4-send-an-email-with-attachments)
5. [Forward attachments](#5-forward-attachments)

## 1. Find the attachments of an email

Attachments are listed inside every email object, in the `attachments` array. You get them when you [list emails](/emails/list) or [retrieve an email](/emails/retrieve).

```bash theme={null}
curl "$EE_URL/api/v1/emails/email_94a0d77db739407e932c3b97fef44aac" \
  -H "X-API-KEY: $EE_KEY"
```

```json Response (part of it) theme={null}
{
  "object": "Email",
  "id": "email_94a0d77db739407e932c3b97fef44aac",
  "subject": "Q3 report",
  "has_attachments": true,
  "attachments": [
    {
      "id": "att_6f2e8c1a0d4b4f7e9a512c3b8d7e1f00",
      "name": "report.pdf",
      "extension": "pdf",
      "size": 4296429,
      "mime": "application/pdf",
      "cid": null,
      "inline": false
    },
    {
      "id": "att_81c4b2d95e6f4a1b8c7d9e0f1a2b3c4d",
      "name": "logo.png",
      "extension": "png",
      "size": 3120,
      "mime": "image/png",
      "cid": "logo@acme",
      "inline": true
    }
  ]
}
```

### The attachment object

<ResponseField name="id" type="string">
  The attachment's `att_…` id. Use it with the email's `id` to [download the file](#2-download-an-attachment).
</ResponseField>

<ResponseField name="name" type="string">
  The file name, for example `report.pdf`. Can be empty if the sender didn't give one.
</ResponseField>

<ResponseField name="extension" type="string">
  The file extension, taken from the name, for example `pdf`. Empty when the name has none.
</ResponseField>

<ResponseField name="size" type="integer">
  Size in bytes. `4296429` is about 4.1 MB.
</ResponseField>

<ResponseField name="mime" type="string">
  The file type (MIME type), for example `application/pdf`, `image/png`, `text/csv`.
</ResponseField>

<ResponseField name="cid" type="string | null">
  The Content-ID. Only set for images that are shown **inside** the email body. See [Show inline images](#3-show-inline-images).
</ResponseField>

<ResponseField name="inline" type="boolean">
  `true`: the file is shown inside the email body (usually a logo or a signature image).
  `false`: a normal attachment, shown as a file under the email.
</ResponseField>

<Tip>
  Most apps show only the attachments with `inline: false` in their attachment list, and use the `inline: true` ones to display the body.
</Tip>

### Find emails that have attachments

Add `has_attachments=true` when you list emails:

```bash theme={null}
curl "$EE_URL/api/v1/emails?account_id=acc_ba1fa6938d8548298e8ce62e4b4b4e99&has_attachments=true" \
  -H "X-API-KEY: $EE_KEY"
```

You can combine it with any other filter, for example `folder=INBOX` or `from=ana@example.com`.

## 2. Download an attachment

```
GET /api/v1/emails/{email_id}/attachments/{attachment_id}
```

<ParamField path="email_id" type="string" required>
  The email's `email_…` id.
</ParamField>

<ParamField path="attachment_id" type="string" required>
  The attachment's `att_…` id, from the email's `attachments` array.
</ParamField>

The response is **the file itself** (binary data), not JSON. Save it as a file, or send it on to your user's browser.

It comes with these headers:

| Header | Example | What it tells you |
| - | - | - |
| `Content-Type` | `application/pdf` | The file type, the same as the attachment's `mime` |
| `Content-Disposition` | `attachment; filename="report.pdf"` | The original file name, so a browser saves it under that name |
| `Content-Length` | `4296429` | The size in bytes |

### Examples

<CodeGroup>
  ```bash cURL theme={null}
  # -o saves the response to a file
  curl "$EE_URL/api/v1/emails/email_94a0d77db739407e932c3b97fef44aac/attachments/att_6f2e8c1a0d4b4f7e9a512c3b8d7e1f00" \
    -H "X-API-KEY: $EE_KEY" \
    -o report.pdf
  ```

  ```js Node.js theme={null}
  import { writeFile } from "node:fs/promises";

  const emailId = "email_94a0d77db739407e932c3b97fef44aac";
  const attachmentId = "att_6f2e8c1a0d4b4f7e9a512c3b8d7e1f00";

  const res = await fetch(`${process.env.EE_URL}/api/v1/emails/${emailId}/attachments/${attachmentId}`, {
    headers: { "X-API-KEY": process.env.EE_KEY },
  });
  if (!res.ok) throw new Error(`Download failed: ${res.status} ${await res.text()}`);

  await writeFile("report.pdf", Buffer.from(await res.arrayBuffer()));
  ```

  ```php PHP (Laravel) theme={null}
  use Illuminate\Support\Facades\Http;
  use Illuminate\Support\Facades\Storage;

  $emailId = 'email_94a0d77db739407e932c3b97fef44aac';
  $attachmentId = 'att_6f2e8c1a0d4b4f7e9a512c3b8d7e1f00';

  $response = Http::withHeaders(['X-API-KEY' => config('services.email_engine.key')])
      ->get(config('services.email_engine.url')."/api/v1/emails/{$emailId}/attachments/{$attachmentId}")
      ->throw();

  Storage::put('attachments/report.pdf', $response->body());
  ```

  ```python Python theme={null}
  import os, requests

  email_id = "email_94a0d77db739407e932c3b97fef44aac"
  attachment_id = "att_6f2e8c1a0d4b4f7e9a512c3b8d7e1f00"

  r = requests.get(
      f"{os.environ['EE_URL']}/api/v1/emails/{email_id}/attachments/{attachment_id}",
      headers={"X-API-KEY": os.environ["EE_KEY"]},
  )
  r.raise_for_status()
  with open("report.pdf", "wb") as f:
      f.write(r.content)
  ```
</CodeGroup>

### Let your users download files

Your access token must stay on your server, so your users' browsers can't call the engine directly. Instead, add a route in your own app that downloads the file from the engine and passes it on:

```php Laravel route theme={null}
Route::get('/emails/{email}/attachments/{attachment}', function (string $email, string $attachment) {
    // Check here that the signed-in user may see this email.
    $r = Http::withHeaders(['X-API-KEY' => config('services.email_engine.key')])
        ->get(config('services.email_engine.url')."/api/v1/emails/{$email}/attachments/{$attachment}")
        ->throw();

    return response($r->body(), 200, [
        'Content-Type' => $r->header('Content-Type'),
        'Content-Disposition' => $r->header('Content-Disposition'),
    ]);
})->middleware('auth');
```

### Errors

| Status | `type` | Why |
| - | - | - |
| 404 | `errors/not_found` | No email with this `email_id`, or no attachment with this `attachment_id` in that email. Ids without the right prefix (`email_…`, `att_…`) are not found either |
| 404 | `errors/not_found` | The email was permanently deleted (emptied from Trash). Emails in Trash still work |
| 401 | `errors/invalid_credentials` | The file had to be fetched from the provider, and the account's access was revoked. [Reconnect the account](/accounts/imap#reconnect-or-change-settings) |
| 502 | `errors/provider_error` | The file had to be fetched from the provider, and the provider didn't answer. Try again later |

See [Errors](/reference/errors) for the error format.

### Where the file comes from

You don't need to do anything for this. It explains why downloads are fast and keep working.

```mermaid theme={null}
flowchart TD
  A[New email arrives] --> B[Engine saves the email<br/>and its attachment list]
  B --> C[Background job copies each file<br/>into your workspace's storage]
  D[You download an attachment] --> E{Already copied?}
  E -- Yes --> F[Served from storage:<br/>fast, no call to the provider]
  E -- Not yet --> G[Fetched from the IMAP server,<br/>then copied for next time]
```

* **New emails are saved right away, without the files.** The engine saves the email and the list of its attachments (name, size, type) first, so a large file never delays new mail or webhooks.
* **A background job then copies every file** into your workspace's storage, a few seconds later.
* **Downloads come from that copy.** They're fast, and they work even when the IMAP server is slow or down.
* **If you download a file before it's copied**, the engine gets it from the provider for you and copies it at the same time.

## 3. Show inline images

Some emails show images **inside** the text, for example a company logo in a signature. Those images are sent as attachments with `inline: true`, and the HTML `body` points to them with a `cid:` link instead of a normal URL:

```html theme={null}
<p>Best regards,</p>
<img src="cid:logo@acme" alt="Acme">
```

A browser can't open `cid:logo@acme`, so the image looks broken if you display the body as it is. To fix it, replace each `cid:` link with a URL that serves the attachment, such as [your own download route](#let-your-users-download-files):

<Steps>
  <Step title="Find the inline attachments">
    Take the attachments that have a `cid`, for example `{ "id": "att_81c4b2d95e6f4a1b8c7d9e0f1a2b3c4d", "cid": "logo@acme" }`.
  </Step>

  <Step title="Replace each cid: link in the body">
    Replace `cid:logo@acme` with your URL for that attachment, for example `/emails/email_94a0d77db739407e932c3b97fef44aac/attachments/att_81c4b2d95e6f4a1b8c7d9e0f1a2b3c4d`.
  </Step>

  <Step title="Display the body">
    The images now load from your app.
  </Step>
</Steps>

```js Node.js theme={null}
function showInlineImages(email) {
  let html = email.body;
  for (const a of email.attachments) {
    if (!a.cid) continue;
    const url = `/emails/${email.id}/attachments/${a.id}`; // your own route
    html = html.replaceAll(`cid:${a.cid}`, url);
  }
  return html;
}
```

<Warning>
  Email HTML comes from outside senders. Clean it with an HTML sanitizer (for example DOMPurify) before you display it, and show it in a sandboxed iframe.
</Warning>

## 4. Send an email with attachments

Use [Send an email](/emails/send) (`POST /api/v1/emails`) and add the files in one of two ways.

### Option A: multipart/form-data (upload files)

Best when you have the files on disk or from an upload form. Put each file in a field named **`attachments`**, and repeat the field for more files. The other fields (`account_id`, `to`, `subject`, `body` …) are normal text fields.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "$EE_URL/api/v1/emails" \
    -H "X-API-KEY: $EE_KEY" \
    -F account_id=acc_ba1fa6938d8548298e8ce62e4b4b4e99 \
    -F 'to=[{"identifier":"ana@example.com"}]' \
    -F subject="Our proposal" \
    -F body="<p>Hi Ana, the proposal and price list are attached.</p>" \
    -F 'attachments=@proposal.pdf;type=application/pdf' \
    -F 'attachments=@prices.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  ```

  ```js Node.js theme={null}
  import { readFile } from "node:fs/promises";

  const form = new FormData();
  form.append("account_id", "acc_ba1fa6938d8548298e8ce62e4b4b4e99");
  form.append("to", JSON.stringify([{ identifier: "ana@example.com" }]));
  form.append("subject", "Our proposal");
  form.append("body", "<p>Hi Ana, the proposal is attached.</p>");
  form.append("attachments", new Blob([await readFile("proposal.pdf")], { type: "application/pdf" }), "proposal.pdf");

  const res = await fetch(`${process.env.EE_URL}/api/v1/emails`, {
    method: "POST",
    headers: { "X-API-KEY": process.env.EE_KEY }, // don't set Content-Type: fetch adds it for FormData
    body: form,
  });
  console.log(await res.json());
  ```

  ```php PHP (Laravel) theme={null}
  $response = Http::withHeaders(['X-API-KEY' => config('services.email_engine.key')])
      ->attach('attachments', file_get_contents('proposal.pdf'), 'proposal.pdf', ['Content-Type' => 'application/pdf'])
      ->post(config('services.email_engine.url').'/api/v1/emails', [
          'account_id' => 'acc_ba1fa6938d8548298e8ce62e4b4b4e99',
          'to' => json_encode([['identifier' => 'ana@example.com']]),
          'subject' => 'Our proposal',
          'body' => '<p>Hi Ana, the proposal is attached.</p>',
      ]);
  ```
</CodeGroup>

In a multipart request, `to`, `cc` and `bcc` can be a JSON list (as above) or simply `ana@example.com, bob@example.com`.

### Option B: JSON (base64 content)

Best when the file is already in memory, for example generated by your app. Send the file content encoded as **base64**:

<ParamField body="attachments" type="object[]">
  One object per file.
</ParamField>

<ParamField body="attachments[].filename" type="string" default="attachment">
  The name the recipient sees, for example `proposal.pdf`.
</ParamField>

<ParamField body="attachments[].content_type" type="string" default="application/octet-stream">
  The file type, for example `application/pdf`.
</ParamField>

<ParamField body="attachments[].content" type="string" required>
  The file content, base64-encoded.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "$EE_URL/api/v1/emails" \
    -H "X-API-KEY: $EE_KEY" -H "Content-Type: application/json" \
    -d '{
      "account_id": "acc_ba1fa6938d8548298e8ce62e4b4b4e99",
      "to": [{ "identifier": "ana@example.com" }],
      "subject": "Our proposal",
      "body": "<p>Hi Ana, the proposal is attached.</p>",
      "attachments": [
        { "filename": "proposal.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjQKJ…" }
      ]
    }'
  ```

  ```js Node.js theme={null}
  import { readFile } from "node:fs/promises";

  const res = await fetch(`${process.env.EE_URL}/api/v1/emails`, {
    method: "POST",
    headers: { "X-API-KEY": process.env.EE_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      account_id: "acc_ba1fa6938d8548298e8ce62e4b4b4e99",
      to: [{ identifier: "ana@example.com" }],
      subject: "Our proposal",
      body: "<p>Hi Ana, the proposal is attached.</p>",
      attachments: [
        {
          filename: "proposal.pdf",
          content_type: "application/pdf",
          content: (await readFile("proposal.pdf")).toString("base64"),
        },
      ],
    }),
  });
  console.log(await res.json());
  ```

  ```php PHP (Laravel) theme={null}
  $response = Http::withHeaders(['X-API-KEY' => config('services.email_engine.key')])
      ->post(config('services.email_engine.url').'/api/v1/emails', [
          'account_id' => 'acc_ba1fa6938d8548298e8ce62e4b4b4e99',
          'to' => [['identifier' => 'ana@example.com']],
          'subject' => 'Our proposal',
          'body' => '<p>Hi Ana, the proposal is attached.</p>',
          'attachments' => [[
              'filename' => 'proposal.pdf',
              'content_type' => 'application/pdf',
              'content' => base64_encode(file_get_contents('proposal.pdf')),
          ]],
      ]);
  ```
</CodeGroup>

### Size limits

| Limit | Size |
| - | - |
| One request to the engine (all files + text) | **30 MB** |
| Gmail (the whole email) | 25 MB |
| Other providers (IMAP / SMTP) | Set by the provider, often 10 to 25 MB |

<Note>
  Base64 makes files about **33% bigger**, so a 20 MB file becomes about 27 MB in a JSON request. For large files, use multipart. If the provider refuses the email, you get an error with the provider's reason in `detail` (see [Errors](/reference/errors)).
</Note>

Attachments work the same way for [replies](/emails/replies-and-threads) (add `reply_to`) and [drafts](/emails/drafts).

## 5. Forward attachments

There's no separate forward call. To forward an email with its files:

1. [Download](#2-download-an-attachment) each attachment of the original email.
2. [Send a new email](#4-send-an-email-with-attachments) with those files, and with the original's text in `body`.

```js Node.js theme={null}
async function forward(email, to) {
  const form = new FormData();
  form.append("account_id", email.account_id);
  form.append("to", to);
  form.append("subject", `Fwd: ${email.subject}`);
  form.append("body", `<p>---------- Forwarded message ----------</p>${email.body}`);

  for (const a of email.attachments.filter((a) => !a.inline)) {
    const res = await fetch(`${process.env.EE_URL}/api/v1/emails/${email.id}/attachments/${a.id}`, {
      headers: { "X-API-KEY": process.env.EE_KEY },
    });
    form.append("attachments", new Blob([await res.arrayBuffer()], { type: a.mime }), a.name || "attachment");
  }

  const res = await fetch(`${process.env.EE_URL}/api/v1/emails`, {
    method: "POST",
    headers: { "X-API-KEY": process.env.EE_KEY },
    body: form,
  });
  return res.json();
}
```

## Questions

<AccordionGroup>
  <Accordion title="Why is has_attachments true, but I only see a logo?">
    Inline images (`inline: true`), like a logo in a signature, are attachments too. Filter on `inline: false` to show only real file attachments.
  </Accordion>

  <Accordion title="Can I download an attachment after the user deleted the email?">
    While the email is in **Trash**, yes: it's still an email in the API (with `role: "TRASH"`), and its attachments download normally. Once it's **permanently deleted** (Trash emptied), the email and its attachments are gone from the API and you get `404`. If you need to keep a file, download it and store it in your app when you receive the `email.new` event.
  </Accordion>

  <Accordion title="Do webhooks include the files?">
    No. An `email.new` event includes the attachment list (`id`, `name`, `extension`, `size`, `mime`), not the file content. Download the files you need with the endpoint above.
  </Accordion>

  <Accordion title="Is there a limit on how many attachments I can send?">
    No fixed number. The total request must stay under 30 MB, and under the provider's own limit.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.